Refactor voice and video module (#310)
Co-authored-by: Matt Lorentz <5+mplorentz@noreply.coracle.social> Co-committed-by: Matt Lorentz <5+mplorentz@noreply.coracle.social>
This commit is contained in:
parent
9955a50add
commit
90561d46ad
11 changed files with 598 additions and 884 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
* 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 {MediaQuery} from "svelte/reactivity"
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
|
|
@ -14,7 +15,7 @@ import {
|
||||||
supportsAudioOutputSelection,
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
||||||
|
|
@ -22,22 +23,8 @@ import {signer} from "@welshman/app"
|
||||||
import {load} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {
|
|
||||||
currentVoiceRoom,
|
|
||||||
currentVoiceSession,
|
|
||||||
voiceMicMuted,
|
|
||||||
participantFromLiveKitIdentity,
|
|
||||||
participantKey,
|
|
||||||
participantMediaState,
|
|
||||||
speakingParticipants,
|
|
||||||
VoiceState,
|
|
||||||
type ParticipantMediaState,
|
|
||||||
type VoiceParticipant,
|
|
||||||
voiceState,
|
|
||||||
} from "@app/call/stores"
|
|
||||||
import {resetVideoCallLayout, triggerVideoFeedCount, videoPrimaryTileKey} from "@app/call/video"
|
|
||||||
import {deriveLatestEventForUrl} from "@app/repository"
|
import {deriveLatestEventForUrl} from "@app/repository"
|
||||||
import {deriveRoom, makeRoomId} from "@app/groups"
|
import {deriveRoom, makeRoomId, type Room} from "@app/groups"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
@ -47,6 +34,43 @@ export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
export {supportsAudioOutputSelection}
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
export type CallSession = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
livekit: LiveKitRoom
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
||||||
|
export const callMicMuted = writable(true)
|
||||||
|
|
||||||
|
export type CallParticipant = {pubkey?: string; liveKitIdentity: string}
|
||||||
|
|
||||||
|
export type ParticipantMediaState = {
|
||||||
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CallState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VideoCallLayout {
|
||||||
|
Chat = "chat",
|
||||||
|
Video = "video",
|
||||||
|
Split = "split",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ViewportSize {
|
||||||
|
Desktop = "desktop",
|
||||||
|
Mobile = "mobile",
|
||||||
|
}
|
||||||
|
|
||||||
export enum DeviceKind {
|
export enum DeviceKind {
|
||||||
AudioInput = "audioinput",
|
AudioInput = "audioinput",
|
||||||
|
|
@ -54,312 +78,65 @@ export enum DeviceKind {
|
||||||
VideoInput = "videoinput",
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const switchVoiceActiveDevice = async (
|
export const currentCallSession = writable<CallSession | undefined>(undefined)
|
||||||
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}`})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
export const callState = writable<CallState>(CallState.Disconnected)
|
||||||
muted: !participant.isMicrophoneEnabled,
|
|
||||||
cameraOn: participant.isCameraEnabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteParticipant = (identity: string) => {
|
export const callTargetRoom = writable<Room | undefined>(undefined)
|
||||||
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncParticipantMedia = (participant: Participant) => {
|
export const speakingParticipants = writable<CallParticipant[]>([])
|
||||||
const state = participantMediaFrom(participant)
|
|
||||||
participantMediaState.update(m => {
|
|
||||||
const prev = m.get(participant.identity)
|
|
||||||
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
|
||||||
const next = new Map(m)
|
|
||||||
next.set(participant.identity, state)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
||||||
const resyncAfterReconnect = (room: LiveKitRoom) => {
|
|
||||||
if (room !== activeRoom) return
|
|
||||||
|
|
||||||
const next = new Map<string, ParticipantMediaState>()
|
export const mediaStateByIdentity = derived(
|
||||||
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
|
[participantMediaState, currentCallSession, callMicMuted],
|
||||||
next.set(p.identity, participantMediaFrom(p))
|
([$media, $session, $micMuted]) =>
|
||||||
}
|
(liveKitIdentity: string) => {
|
||||||
participantMediaState.set(next)
|
if ($session?.livekit.localParticipant.identity === liveKitIdentity) {
|
||||||
|
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
||||||
const session = get(currentVoiceSession)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const {localParticipant} = room
|
|
||||||
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
|
||||||
currentVoiceSession.set({
|
|
||||||
...session,
|
|
||||||
cameraOn: localParticipant.isCameraEnabled,
|
|
||||||
screenShareOn: localParticipant.isScreenShareEnabled,
|
|
||||||
})
|
|
||||||
triggerVideoFeedCount()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
|
||||||
syncParticipantMedia(participant)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
|
||||||
url: string,
|
|
||||||
groupId: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<{server_url: string; participant_token: string}> => {
|
|
||||||
const endpoint = getLivekitEndpoint(url, groupId)
|
|
||||||
|
|
||||||
const $signer = signer.get()
|
|
||||||
if (!$signer) throw new Error("No signer available")
|
|
||||||
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
||||||
|
|
||||||
const template = await makeHttpAuth(endpoint, "GET")
|
|
||||||
const signedEvent = await $signer.sign(template)
|
|
||||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
headers: {Authorization: authHeader},
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(`Token request failed (${response.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadVoiceParticipants = (url: string, h: string) =>
|
|
||||||
load({
|
|
||||||
relays: [url],
|
|
||||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveVoiceParticipants = (url: string, h: string) =>
|
|
||||||
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
|
||||||
derived(
|
|
||||||
[
|
|
||||||
participantMediaState,
|
|
||||||
currentVoiceRoom,
|
|
||||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
|
||||||
],
|
|
||||||
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
|
||||||
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
|
||||||
|
|
||||||
if (inCall) {
|
|
||||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
|
||||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
|
||||||
} else {
|
|
||||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
|
||||||
if (!latestEvent) return []
|
|
||||||
const participants = removeUndefined(
|
|
||||||
map(
|
|
||||||
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
|
||||||
getTags("participant", latestEvent.tags),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
|
||||||
}
|
}
|
||||||
|
return $media.get(liveKitIdentity) ?? {muted: true, cameraOn: false}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const setUpMicrophone = async (
|
export const isParticipantSpeaking = derived(
|
||||||
startMuted: boolean,
|
speakingParticipants,
|
||||||
preferredMicId: string | undefined,
|
$participants => (p: CallParticipant) =>
|
||||||
participant: LocalParticipant,
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
signal?: AbortSignal,
|
)
|
||||||
settleSignal?: AbortSignal,
|
|
||||||
): Promise<boolean> => {
|
|
||||||
if (startMuted) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let muted = true
|
export const isLocalSpeaking = derived(
|
||||||
let capture: AudioCaptureOptions | undefined = undefined
|
[currentCallSession, speakingParticipants],
|
||||||
if (preferredMicId) {
|
([$session, $speaking]) => {
|
||||||
capture = {deviceId: preferredMicId}
|
if (!$session?.livekit) return false
|
||||||
}
|
const local = participantFromLiveKitIdentity($session.livekit.localParticipant.identity)
|
||||||
try {
|
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||||
await Promise.race([
|
},
|
||||||
participant.setMicrophoneEnabled(true, capture),
|
)
|
||||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
|
||||||
whenAborted(signal),
|
export const videoTrackRevision = writable(0)
|
||||||
])
|
|
||||||
muted = false
|
export const triggerVideoTrackRevision = () => {
|
||||||
} catch (e) {
|
videoTrackRevision.update(n => n + 1)
|
||||||
// Timeout or microphone rejection: join muted, the call is still usable. A
|
|
||||||
// genuine abort is surfaced to the caller so it can tear down the room.
|
|
||||||
if (e instanceof AbortError) throw e
|
|
||||||
if (!(e instanceof TimeoutError)) {
|
|
||||||
pushToast({theme: "error", message: "Could not access microphone"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return muted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
||||||
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
|
||||||
let activeRoom: LiveKitRoom | undefined
|
|
||||||
|
|
||||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
export const videoCallViewportSync = {
|
||||||
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
previousLayout: undefined as ViewportSize | undefined,
|
||||||
let reconnectAttempt = 0
|
|
||||||
|
|
||||||
const clearReconnectSchedule = () => {
|
|
||||||
if (reconnectTimeout !== undefined) {
|
|
||||||
clearTimeout(reconnectTimeout)
|
|
||||||
reconnectTimeout = undefined
|
|
||||||
}
|
|
||||||
reconnectAttempt = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const attemptReconnect = async () => {
|
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
||||||
const target = get(currentVoiceRoom)
|
|
||||||
if (!target) return
|
|
||||||
|
|
||||||
try {
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
await joinVoiceRoom(target.url, target.h)
|
|
||||||
} catch {
|
|
||||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
|
||||||
pushToast({theme: "error", message: "Voice connection lost."})
|
|
||||||
clearReconnectSchedule()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
export const videoTileCount = derived(
|
||||||
if (reconnectTimeout !== undefined) return
|
[currentCallSession, callState, videoTrackRevision],
|
||||||
if (!get(currentVoiceRoom)) return
|
([$session, $state]) => {
|
||||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
if ($state !== CallState.Connected || !$session) return 0
|
||||||
pushToast({theme: "error", message: "Voice connection lost."})
|
return countLiveVisualFeeds($session)
|
||||||
return
|
},
|
||||||
}
|
)
|
||||||
|
|
||||||
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
|
||||||
reconnectAttempt++
|
|
||||||
reconnectTimeout = setTimeout(() => {
|
|
||||||
reconnectTimeout = undefined
|
|
||||||
void attemptReconnect()
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
|
|
||||||
if (room !== activeRoom) return
|
|
||||||
resyncAfterReconnect(room)
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
|
|
||||||
// Ignore disconnects from rooms that are no longer the active session.
|
|
||||||
if (room !== activeRoom) return
|
|
||||||
|
|
||||||
activeRoom = undefined
|
|
||||||
room.removeAllListeners()
|
|
||||||
|
|
||||||
videoPrimaryTileKey.set(undefined)
|
|
||||||
voiceMicMuted.set(true)
|
|
||||||
currentVoiceSession.set(undefined)
|
|
||||||
resetVideoCallLayout()
|
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
|
||||||
if (reason === DisconnectReason.JOIN_FAILURE) {
|
|
||||||
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
|
||||||
} else if (get(currentVoiceRoom)) {
|
|
||||||
clearReconnectSchedule()
|
|
||||||
scheduleReconnect()
|
|
||||||
} else {
|
|
||||||
pushToast({theme: "error", message: "Voice connection lost."})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
speakingParticipants.set([])
|
|
||||||
participantMediaState.set(new Map())
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTrackSubscribed = (track: Track) => {
|
|
||||||
if (track.kind === Track.Kind.Audio) {
|
|
||||||
const element = track.attach()
|
|
||||||
element.style.display = "none"
|
|
||||||
document.body.appendChild(element)
|
|
||||||
element.play().catch(() => {})
|
|
||||||
} else if (track.kind === Track.Kind.Video) {
|
|
||||||
triggerVideoFeedCount()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTrackUnsubscribed = (track: Track) => {
|
|
||||||
track.detach().forEach(el => el.remove())
|
|
||||||
if (track.kind === Track.Kind.Video) {
|
|
||||||
triggerVideoFeedCount()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
|
||||||
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const playJoinSound = () => {
|
|
||||||
const audio = new Audio("/join-voice-room.mp3")
|
|
||||||
audio.play().catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onParticipantConnected = (participant: Participant) => {
|
|
||||||
syncParticipantMedia(participant)
|
|
||||||
playJoinSound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onParticipantDisconnected = (participant: {identity: string}) => {
|
|
||||||
deleteParticipant(participant.identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLocalTrackUnpublished = (
|
|
||||||
publication: LocalTrackPublication,
|
|
||||||
participant: LocalParticipant,
|
|
||||||
) => {
|
|
||||||
if (publication.source !== Track.Source.ScreenShare) return
|
|
||||||
const session = get(currentVoiceSession)
|
|
||||||
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
|
||||||
if (!session.screenShareOn) return
|
|
||||||
currentVoiceSession.set({...session, screenShareOn: false})
|
|
||||||
}
|
|
||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
|
||||||
|
|
||||||
const abortJoinVoiceRoom = () => {
|
|
||||||
joinAbortController?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
|
||||||
clearReconnectSchedule()
|
|
||||||
abortJoinVoiceRoom()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const joinVoiceRoom = async (
|
export const joinVoiceRoom = async (
|
||||||
url: string,
|
url: string,
|
||||||
|
|
@ -369,8 +146,8 @@ export const joinVoiceRoom = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
abortJoinVoiceRoom()
|
abortJoinVoiceRoom()
|
||||||
|
|
||||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
callTargetRoom.set(get(deriveRoom(url, h)))
|
||||||
voiceState.set(VoiceState.Joining)
|
callState.set(CallState.Joining)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
joinAbortController = controller
|
joinAbortController = controller
|
||||||
|
|
@ -384,7 +161,7 @@ export const joinVoiceRoom = async (
|
||||||
try {
|
try {
|
||||||
// Tear down any existing session before joining. Bound it so a slow leave
|
// Tear down any existing session before joining. Bound it so a slow leave
|
||||||
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
|
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
|
||||||
if (get(currentVoiceSession)) {
|
if (get(currentCallSession)) {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
leaveVoiceRoom(),
|
leaveVoiceRoom(),
|
||||||
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
|
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
|
||||||
|
|
@ -393,8 +170,8 @@ export const joinVoiceRoom = async (
|
||||||
if (e instanceof AbortError) throw e
|
if (e instanceof AbortError) throw e
|
||||||
})
|
})
|
||||||
|
|
||||||
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
|
// leaveVoiceRoom flips callState to Disconnected; re-assert Joining.
|
||||||
voiceState.set(VoiceState.Joining)
|
callState.set(CallState.Joining)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signal.aborted) throw new AbortError()
|
if (signal.aborted) throw new AbortError()
|
||||||
|
|
@ -468,19 +245,19 @@ export const joinVoiceRoom = async (
|
||||||
throw new AbortError()
|
throw new AbortError()
|
||||||
}
|
}
|
||||||
|
|
||||||
voiceMicMuted.set(muted)
|
callMicMuted.set(muted)
|
||||||
currentVoiceSession.set({
|
currentCallSession.set({
|
||||||
url,
|
url,
|
||||||
h,
|
h,
|
||||||
room: liveKitRoom,
|
livekit: liveKitRoom,
|
||||||
cameraOn: false,
|
cameraOn: false,
|
||||||
screenShareOn: false,
|
screenShareOn: false,
|
||||||
})
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
callState.set(CallState.Connected)
|
||||||
clearReconnectSchedule()
|
clearReconnectSchedule()
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
if (isActive()) callState.set(CallState.Disconnected)
|
||||||
if (e instanceof AbortError) {
|
if (e instanceof AbortError) {
|
||||||
clearReconnectSchedule()
|
clearReconnectSchedule()
|
||||||
return
|
return
|
||||||
|
|
@ -494,7 +271,7 @@ export const joinVoiceRoom = async (
|
||||||
|
|
||||||
export const leaveVoiceRoom = async () => {
|
export const leaveVoiceRoom = async () => {
|
||||||
clearReconnectSchedule()
|
clearReconnectSchedule()
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentCallSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
|
|
@ -502,7 +279,7 @@ export const leaveVoiceRoom = async () => {
|
||||||
|
|
||||||
if (session.cameraOn) {
|
if (session.cameraOn) {
|
||||||
try {
|
try {
|
||||||
await session.room.localParticipant.setCameraEnabled(false)
|
await session.livekit.localParticipant.setCameraEnabled(false)
|
||||||
} catch {
|
} catch {
|
||||||
pushToast({theme: "error", message: "Error turning off camera."})
|
pushToast({theme: "error", message: "Error turning off camera."})
|
||||||
}
|
}
|
||||||
|
|
@ -510,46 +287,429 @@ export const leaveVoiceRoom = async () => {
|
||||||
|
|
||||||
if (session.screenShareOn) {
|
if (session.screenShareOn) {
|
||||||
try {
|
try {
|
||||||
await session.room.localParticipant.setScreenShareEnabled(false)
|
await session.livekit.localParticipant.setScreenShareEnabled(false)
|
||||||
} catch {
|
} catch {
|
||||||
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
pushToast({theme: "error", message: "Error turning off screen sharing."})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always tear down this room's connection and listeners.
|
// Always tear down this room's connection and listeners.
|
||||||
if (activeRoom === session.room) activeRoom = undefined
|
if (activeRoom === session.livekit) activeRoom = undefined
|
||||||
session.room.removeAllListeners()
|
session.livekit.removeAllListeners()
|
||||||
session.room.disconnect()
|
session.livekit.disconnect()
|
||||||
|
|
||||||
// Only reset shared UI state if this session is still current. A slow leave
|
// Only reset shared UI state if this session is still current. A slow leave
|
||||||
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
|
// that was superseded by a new join (bounded by a timeout in joinVoiceRoom)
|
||||||
// must not clobber the freshly-joined session when it finally completes.
|
// must not clobber the freshly-joined session when it finally completes.
|
||||||
if (get(currentVoiceSession) === session) {
|
if (get(currentCallSession) === session) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
callState.set(CallState.Disconnected)
|
||||||
videoPrimaryTileKey.set(undefined)
|
callMicMuted.set(true)
|
||||||
voiceMicMuted.set(true)
|
currentCallSession.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
|
||||||
resetVideoCallLayout()
|
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantMediaState.set(new Map())
|
participantMediaState.set(new Map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cancelJoinVoiceRoom = () => {
|
||||||
|
clearReconnectSchedule()
|
||||||
|
abortJoinVoiceRoom()
|
||||||
|
}
|
||||||
|
|
||||||
export const toggleMute = async () => {
|
export const toggleMute = async () => {
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentCallSession)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
voiceMicMuted.update(not)
|
callMicMuted.update(not)
|
||||||
if (get(voiceMicMuted)) {
|
if (get(callMicMuted)) {
|
||||||
// Disable and re-enable microphone to trigger permission prompt
|
session.livekit.localParticipant.setMicrophoneEnabled(false)
|
||||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
await session.livekit.localParticipant.setMicrophoneEnabled(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
voiceMicMuted.set(true)
|
callMicMuted.set(true)
|
||||||
pushToast({theme: "error", message: "Could not access microphone"})
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentCallSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
try {
|
||||||
|
await session.livekit.localParticipant.setCameraEnabled(cameraOn)
|
||||||
|
currentCallSession.set({...session, cameraOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentCallSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
try {
|
||||||
|
await session.livekit.localParticipant.setScreenShareEnabled(screenShareOn)
|
||||||
|
currentCallSession.set({...session, screenShareOn})
|
||||||
|
} catch {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const switchCallActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const session = get(currentCallSession)
|
||||||
|
if (!session) return
|
||||||
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
|
try {
|
||||||
|
await session.livekit.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 resetVideoCallLayout = () => {
|
||||||
|
videoCallViewportSync.previousLayout = undefined
|
||||||
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadCallParticipants = (url: string, h: string) =>
|
||||||
|
load({
|
||||||
|
relays: [url],
|
||||||
|
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deriveCallParticipants = (url: string, h: string) =>
|
||||||
|
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||||
|
derived(
|
||||||
|
[
|
||||||
|
participantMediaState,
|
||||||
|
callTargetRoom,
|
||||||
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
|
],
|
||||||
|
([$participantMediaState, $callTargetRoom, $publishedParticipantList]) => {
|
||||||
|
const inCall = $participantMediaState.size > 0 && $callTargetRoom?.id === makeRoomId(url, h)
|
||||||
|
|
||||||
|
if (inCall) {
|
||||||
|
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||||
|
return uniqBy((p: CallParticipant) => participantKey(p), participants)
|
||||||
|
} else {
|
||||||
|
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||||
|
if (!latestEvent) return []
|
||||||
|
const participants = removeUndefined(
|
||||||
|
map(
|
||||||
|
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
||||||
|
getTags("participant", latestEvent.tags),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return uniqBy((p: CallParticipant) => participantKey(p), participants)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const pubkeyFromLiveKitIdentity = (liveKitIdentity: string): string | undefined =>
|
||||||
|
/^[a-f0-9]{64}$/.test(liveKitIdentity.slice(0, 64)) ? liveKitIdentity.slice(0, 64) : undefined
|
||||||
|
|
||||||
|
export const participantFromLiveKitIdentity = (liveKitIdentity: string): CallParticipant => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
|
||||||
|
return pk ? {pubkey: pk, liveKitIdentity} : {liveKitIdentity}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdentity
|
||||||
|
|
||||||
|
// The room whose events are allowed to mutate shared state. Abandoned rooms
|
||||||
|
// (after switching calls or an engine reconnect give-up) must not clobber it.
|
||||||
|
let activeRoom: LiveKitRoom | undefined
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let joinAbortController: AbortController | undefined
|
||||||
|
let hadCallSession = false
|
||||||
|
|
||||||
|
currentCallSession.subscribe(session => {
|
||||||
|
if (session) {
|
||||||
|
hadCallSession = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hadCallSession) return
|
||||||
|
hadCallSession = false
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
resetVideoCallLayout()
|
||||||
|
})
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (session: CallSession): number => {
|
||||||
|
const livekit = session.livekit
|
||||||
|
let n = 0
|
||||||
|
const lp = livekit.localParticipant
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of livekit.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||||
|
muted: !participant.isMicrophoneEnabled,
|
||||||
|
cameraOn: participant.isCameraEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteParticipant = (liveKitIdentity: string) => {
|
||||||
|
participantMediaState.update(m => new Map(reject(nthEq(0, liveKitIdentity), [...m])))
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncParticipantMedia = (participant: Participant) => {
|
||||||
|
const state = participantMediaFrom(participant)
|
||||||
|
participantMediaState.update(m => {
|
||||||
|
const prev = m.get(participant.identity)
|
||||||
|
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||||
|
const next = new Map(m)
|
||||||
|
next.set(participant.identity, state)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
||||||
|
const resyncAfterReconnect = (livekit: LiveKitRoom) => {
|
||||||
|
if (livekit !== activeRoom) return
|
||||||
|
|
||||||
|
const next = new Map<string, ParticipantMediaState>()
|
||||||
|
for (const p of [livekit.localParticipant, ...livekit.remoteParticipants.values()]) {
|
||||||
|
next.set(p.identity, participantMediaFrom(p))
|
||||||
|
}
|
||||||
|
participantMediaState.set(next)
|
||||||
|
|
||||||
|
const session = get(currentCallSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const {localParticipant} = livekit
|
||||||
|
callMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
||||||
|
currentCallSession.set({
|
||||||
|
...session,
|
||||||
|
cameraOn: localParticipant.isCameraEnabled,
|
||||||
|
screenShareOn: localParticipant.isScreenShareEnabled,
|
||||||
|
})
|
||||||
|
triggerVideoTrackRevision()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLivekitToken = async (
|
||||||
|
url: string,
|
||||||
|
groupId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{server_url: string; participant_token: string}> => {
|
||||||
|
const endpoint = getLivekitEndpoint(url, groupId)
|
||||||
|
|
||||||
|
const $signer = signer.get()
|
||||||
|
if (!$signer) throw new Error("No signer available")
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||||
|
|
||||||
|
const template = await makeHttpAuth(endpoint, "GET")
|
||||||
|
const signedEvent = await $signer.sign(template)
|
||||||
|
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: {Authorization: authHeader},
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUpMicrophone = async (
|
||||||
|
startMuted: boolean,
|
||||||
|
preferredMicId: string | undefined,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
settleSignal?: AbortSignal,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (startMuted) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let muted = true
|
||||||
|
let capture: AudioCaptureOptions | undefined = undefined
|
||||||
|
if (preferredMicId) {
|
||||||
|
capture = {deviceId: preferredMicId}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
participant.setMicrophoneEnabled(true, capture),
|
||||||
|
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||||
|
whenAborted(signal),
|
||||||
|
])
|
||||||
|
muted = false
|
||||||
|
} catch (e) {
|
||||||
|
// Timeout or microphone rejection: join muted, the call is still usable. A
|
||||||
|
// genuine abort is surfaced to the caller so it can tear down the room.
|
||||||
|
if (e instanceof AbortError) throw e
|
||||||
|
if (!(e instanceof TimeoutError)) {
|
||||||
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return muted
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearReconnectSchedule = () => {
|
||||||
|
if (reconnectTimeout !== undefined) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
reconnectTimeout = undefined
|
||||||
|
}
|
||||||
|
reconnectAttempt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptReconnect = async () => {
|
||||||
|
const target = get(callTargetRoom)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await joinVoiceRoom(target.url, target.h)
|
||||||
|
} catch {
|
||||||
|
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||||
|
pushToast({theme: "error", message: "Voice connection lost."})
|
||||||
|
clearReconnectSchedule()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (reconnectTimeout !== undefined) return
|
||||||
|
if (!get(callTargetRoom)) return
|
||||||
|
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||||
|
pushToast({theme: "error", message: "Voice connection lost."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
||||||
|
reconnectAttempt++
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectTimeout = undefined
|
||||||
|
void attemptReconnect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeOnRoomReconnected = (livekit: LiveKitRoom) => () => {
|
||||||
|
if (livekit !== activeRoom) return
|
||||||
|
resyncAfterReconnect(livekit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectReason) => {
|
||||||
|
// Ignore disconnects from rooms that are no longer the active session.
|
||||||
|
if (livekit !== activeRoom) return
|
||||||
|
|
||||||
|
activeRoom = undefined
|
||||||
|
livekit.removeAllListeners()
|
||||||
|
|
||||||
|
callMicMuted.set(true)
|
||||||
|
currentCallSession.set(undefined)
|
||||||
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
|
callState.set(CallState.Disconnected)
|
||||||
|
if (reason === DisconnectReason.JOIN_FAILURE) {
|
||||||
|
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
||||||
|
} else if (get(callTargetRoom)) {
|
||||||
|
clearReconnectSchedule()
|
||||||
|
scheduleReconnect()
|
||||||
|
} else {
|
||||||
|
pushToast({theme: "error", message: "Voice connection lost."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
speakingParticipants.set([])
|
||||||
|
participantMediaState.set(new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||||
|
syncParticipantMedia(participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTrackSubscribed = (track: Track) => {
|
||||||
|
if (track.kind === Track.Kind.Audio) {
|
||||||
|
const element = track.attach()
|
||||||
|
element.style.display = "none"
|
||||||
|
document.body.appendChild(element)
|
||||||
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoTrackRevision()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
triggerVideoTrackRevision()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
|
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const playJoinSound = () => {
|
||||||
|
const audio = new Audio("/join-voice-room.mp3")
|
||||||
|
audio.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParticipantConnected = (participant: Participant) => {
|
||||||
|
syncParticipantMedia(participant)
|
||||||
|
playJoinSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onParticipantDisconnected = (participant: {identity: string}) => {
|
||||||
|
deleteParticipant(participant.identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentCallSession)
|
||||||
|
if (!session || participant.identity !== session.livekit.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentCallSession.set({...session, screenShareOn: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortJoinVoiceRoom = () => {
|
||||||
|
joinAbortController?.abort()
|
||||||
|
}
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import {Room as LiveKitRoom} from "livekit-client"
|
|
||||||
import {derived, writable} from "svelte/store"
|
|
||||||
import type {Room} from "@app/groups"
|
|
||||||
|
|
||||||
export type VoiceSession = {
|
|
||||||
url: string
|
|
||||||
h: string
|
|
||||||
room: LiveKitRoom
|
|
||||||
cameraOn: boolean
|
|
||||||
screenShareOn: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mic mute state is separate so toggling it does not re-render video tiles. */
|
|
||||||
export const voiceMicMuted = writable(true)
|
|
||||||
|
|
||||||
export type Pubkey = string
|
|
||||||
|
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
|
||||||
|
|
||||||
export type ParticipantMediaState = {
|
|
||||||
muted: boolean
|
|
||||||
cameraOn: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum VoiceState {
|
|
||||||
Joining = "joining",
|
|
||||||
Connected = "connected",
|
|
||||||
Disconnected = "disconnected",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
|
||||||
|
|
||||||
export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined =>
|
|
||||||
/^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined
|
|
||||||
|
|
||||||
export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => {
|
|
||||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
|
||||||
return pk ? {pubkey: pk, identity} : {identity}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
|
|
||||||
|
|
||||||
export const speakingParticipants = writable<VoiceParticipant[]>([])
|
|
||||||
|
|
||||||
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
|
|
||||||
|
|
||||||
export const mediaStateByIdentity = derived(
|
|
||||||
[participantMediaState, currentVoiceSession, voiceMicMuted],
|
|
||||||
([$media, $session, $micMuted]) =>
|
|
||||||
(identity: string) => {
|
|
||||||
if ($session?.room.localParticipant.identity === identity) {
|
|
||||||
return {muted: $micMuted, cameraOn: $session.cameraOn}
|
|
||||||
}
|
|
||||||
return $media.get(identity) ?? {muted: true, cameraOn: false}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const isParticipantSpeaking = derived(
|
|
||||||
speakingParticipants,
|
|
||||||
$participants => (p: VoiceParticipant) =>
|
|
||||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const isLocalSpeaking = derived(
|
|
||||||
[currentVoiceSession, speakingParticipants],
|
|
||||||
([$session, $speaking]) => {
|
|
||||||
if (!$session?.room) return false
|
|
||||||
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
|
||||||
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import {Track} from "livekit-client"
|
|
||||||
import {MediaQuery} from "svelte/reactivity"
|
|
||||||
import {derived, get, writable} from "svelte/store"
|
|
||||||
import {currentVoiceSession, VoiceState, type VoiceSession, voiceState} from "@app/call/stores"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
export enum VideoCallLayout {
|
|
||||||
Chat = "chat",
|
|
||||||
Video = "video",
|
|
||||||
Split = "split",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isDesktopLayout = new MediaQuery("min-width: 768px", false)
|
|
||||||
|
|
||||||
export enum ViewportSize {
|
|
||||||
Desktop = "desktop",
|
|
||||||
Mobile = "mobile",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const videoCallViewportSync = {
|
|
||||||
previousLayout: undefined as ViewportSize | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const videoCallLayout = writable<VideoCallLayout>(VideoCallLayout.Split)
|
|
||||||
|
|
||||||
export const resetVideoCallLayout = () => {
|
|
||||||
videoCallViewportSync.previousLayout = undefined
|
|
||||||
videoCallLayout.set(VideoCallLayout.Chat)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
|
||||||
|
|
||||||
export const toggleVideoPrimaryTile = (key: string) => {
|
|
||||||
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
|
||||||
}
|
|
||||||
|
|
||||||
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
|
||||||
|
|
||||||
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
|
||||||
const room = session.room
|
|
||||||
let n = 0
|
|
||||||
const lp = room.localParticipant
|
|
||||||
if (session.cameraOn) {
|
|
||||||
const pub = lp.getTrackPublication(Track.Source.Camera)
|
|
||||||
if (pub?.track) n += 1
|
|
||||||
}
|
|
||||||
if (session.screenShareOn) {
|
|
||||||
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
|
||||||
if (pub?.track) n += 1
|
|
||||||
}
|
|
||||||
for (const rp of room.remoteParticipants.values()) {
|
|
||||||
for (const source of VISUAL_SOURCES) {
|
|
||||||
const pub = rp.getTrackPublication(source)
|
|
||||||
if (pub?.isSubscribed && pub.track) n += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
export const triggerVideoFeedCount = () => {
|
|
||||||
currentVoiceSession.update(s => (s ? {...s} : s))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const videoTileCount = derived([currentVoiceSession, voiceState], ([$session, $state]) => {
|
|
||||||
if ($state !== VoiceState.Connected || !$session) return 0
|
|
||||||
return countLiveVisualFeeds($session)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const toggleCamera = async () => {
|
|
||||||
const session = get(currentVoiceSession)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const cameraOn = !session.cameraOn
|
|
||||||
try {
|
|
||||||
await session.room.localParticipant.setCameraEnabled(cameraOn)
|
|
||||||
currentVoiceSession.set({...session, cameraOn})
|
|
||||||
} catch {
|
|
||||||
pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: cameraOn ? "Could not access camera" : "Could not turn off camera",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toggleScreenShare = async () => {
|
|
||||||
const session = get(currentVoiceSession)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const screenShareOn = !session.screenShareOn
|
|
||||||
try {
|
|
||||||
await session.room.localParticipant.setScreenShareEnabled(screenShareOn)
|
|
||||||
currentVoiceSession.set({...session, screenShareOn})
|
|
||||||
} catch {
|
|
||||||
pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: screenShareOn ? "Could not start screen sharing" : "Could not stop screen sharing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,13 +18,11 @@
|
||||||
videoCallViewportSync,
|
videoCallViewportSync,
|
||||||
ViewportSize,
|
ViewportSize,
|
||||||
videoPrimaryTileKey,
|
videoPrimaryTileKey,
|
||||||
} from "@app/call/video"
|
currentCallSession,
|
||||||
import {
|
callTargetRoom,
|
||||||
currentVoiceSession,
|
|
||||||
currentVoiceRoom,
|
|
||||||
mediaStateByIdentity,
|
mediaStateByIdentity,
|
||||||
pubkeyFromLiveKitIdentity,
|
pubkeyFromLiveKitIdentity,
|
||||||
} from "@app/call/stores"
|
} from "@app/call"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
layout: VideoCallLayout
|
layout: VideoCallLayout
|
||||||
|
|
@ -35,7 +33,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoTileData = {
|
type VideoTileData = {
|
||||||
identity: string
|
liveKitIdentity: string
|
||||||
isLocal: boolean
|
isLocal: boolean
|
||||||
trackSid: string
|
trackSid: string
|
||||||
track: Track | undefined
|
track: Track | undefined
|
||||||
|
|
@ -64,7 +62,7 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const isViewingCurrentCallRoom = $derived(
|
const isViewingCurrentCallRoom = $derived(
|
||||||
$currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
$callTargetRoom?.url === url && $callTargetRoom?.h === h,
|
||||||
)
|
)
|
||||||
|
|
||||||
const showVideoContent = $derived(
|
const showVideoContent = $derived(
|
||||||
|
|
@ -75,19 +73,19 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoTiles = $derived.by(() => {
|
const videoTiles = $derived.by(() => {
|
||||||
const session = $currentVoiceSession
|
const session = $currentCallSession
|
||||||
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
|
if (!session || $callTargetRoom?.url !== url || $callTargetRoom?.h !== h) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const room = session.room
|
const livekit = session.livekit
|
||||||
const videoTiles: VideoTileData[] = []
|
const videoTiles: VideoTileData[] = []
|
||||||
const user = room.localParticipant
|
const user = livekit.localParticipant
|
||||||
|
|
||||||
if (session.cameraOn) {
|
if (session.cameraOn) {
|
||||||
const localPub = user.getTrackPublication(Track.Source.Camera)
|
const localPub = user.getTrackPublication(Track.Source.Camera)
|
||||||
videoTiles.push({
|
videoTiles.push({
|
||||||
identity: user.identity,
|
liveKitIdentity: user.identity,
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
trackSid: localPub?.trackSid ?? "local-camera",
|
trackSid: localPub?.trackSid ?? "local-camera",
|
||||||
track: localPub?.track,
|
track: localPub?.track,
|
||||||
|
|
@ -98,7 +96,7 @@
|
||||||
if (session.screenShareOn) {
|
if (session.screenShareOn) {
|
||||||
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
||||||
videoTiles.push({
|
videoTiles.push({
|
||||||
identity: user.identity,
|
liveKitIdentity: user.identity,
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
trackSid: localPub?.trackSid ?? "local-screen",
|
trackSid: localPub?.trackSid ?? "local-screen",
|
||||||
track: localPub?.track,
|
track: localPub?.track,
|
||||||
|
|
@ -106,11 +104,11 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rp of room.remoteParticipants.values()) {
|
for (const rp of livekit.remoteParticipants.values()) {
|
||||||
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
||||||
if (camPub?.isSubscribed && camPub.track) {
|
if (camPub?.isSubscribed && camPub.track) {
|
||||||
videoTiles.push({
|
videoTiles.push({
|
||||||
identity: rp.identity,
|
liveKitIdentity: rp.identity,
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
trackSid: camPub.trackSid,
|
trackSid: camPub.trackSid,
|
||||||
track: camPub.track,
|
track: camPub.track,
|
||||||
|
|
@ -120,16 +118,16 @@
|
||||||
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
if (screenPub?.isSubscribed && screenPub.track) {
|
if (screenPub?.isSubscribed && screenPub.track) {
|
||||||
videoTiles.push({
|
videoTiles.push({
|
||||||
identity: rp.identity,
|
liveKitIdentity: rp.identity,
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
trackSid: screenPub.trackSid,
|
trackSid: screenPub.trackSid,
|
||||||
track: screenPub.track,
|
track: screenPub.track,
|
||||||
source: Track.Source.ScreenShare,
|
source: Track.Source.ScreenShare,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!videoTiles.some(t => t.identity === rp.identity)) {
|
if (!videoTiles.some(t => t.liveKitIdentity === rp.identity)) {
|
||||||
videoTiles.push({
|
videoTiles.push({
|
||||||
identity: rp.identity,
|
liveKitIdentity: rp.identity,
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
trackSid: `avatar-${rp.identity}`,
|
trackSid: `avatar-${rp.identity}`,
|
||||||
track: undefined,
|
track: undefined,
|
||||||
|
|
@ -138,9 +136,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!videoTiles.some(t => t.identity === user.identity)) {
|
if (!videoTiles.some(t => t.liveKitIdentity === user.identity)) {
|
||||||
videoTiles.push({
|
videoTiles.push({
|
||||||
identity: user.identity,
|
liveKitIdentity: user.identity,
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
trackSid: "local-avatar",
|
trackSid: "local-avatar",
|
||||||
track: undefined,
|
track: undefined,
|
||||||
|
|
@ -151,8 +149,8 @@
|
||||||
return videoTiles
|
return videoTiles
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
/** LiveKit identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
||||||
const tileKey = (t: VideoTileData) => `${t.identity}\x1f${t.source}`
|
const tileKey = (t: VideoTileData) => `${t.liveKitIdentity}\x1f${t.source}`
|
||||||
|
|
||||||
const primaryTile = $derived.by(() => {
|
const primaryTile = $derived.by(() => {
|
||||||
const k = $videoPrimaryTileKey
|
const k = $videoPrimaryTileKey
|
||||||
|
|
@ -183,13 +181,13 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const t of videoTiles) {
|
for (const t of videoTiles) {
|
||||||
const pk = pubkeyFromLiveKitIdentity(t.identity)
|
const pk = pubkeyFromLiveKitIdentity(t.liveKitIdentity)
|
||||||
if (pk) loadProfile(pk)
|
if (pk) loadProfile(pk)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const labelFor = (identity: string, source: VideoTileData["source"]) => {
|
const labelFor = (liveKitIdentity: string, source: VideoTileData["source"]) => {
|
||||||
const pk = pubkeyFromLiveKitIdentity(identity)
|
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
|
||||||
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
|
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
|
||||||
return source === Track.Source.ScreenShare ? `${name} · screen` : name
|
return source === Track.Source.ScreenShare ? `${name} · screen` : name
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +210,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
|
||||||
{@const media = $mediaStateByIdentity(tile.identity)}
|
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"relative isolate overflow-hidden rounded-box shadow-sm",
|
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||||
|
|
@ -229,7 +227,7 @@
|
||||||
class="pointer-events-none absolute inset-0" />
|
class="pointer-events-none absolute inset-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.liveKitIdentity)} {url} size={14} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tile.track}
|
{#if tile.track}
|
||||||
|
|
@ -243,7 +241,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
||||||
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||||
</span>
|
</span>
|
||||||
{#if videoTiles.length > 1}
|
{#if videoTiles.length > 1}
|
||||||
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,17 @@
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import {currentVoiceSession, type VoiceSession} from "@app/call/stores"
|
import {
|
||||||
import {DeviceKind, supportsAudioOutputSelection, switchVoiceActiveDevice} from "@app/call/voice"
|
currentCallSession,
|
||||||
|
DeviceKind,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
|
switchCallActiveDevice,
|
||||||
|
type CallSession,
|
||||||
|
} from "@app/call"
|
||||||
import {popModal} from "@app/modal"
|
import {popModal} from "@app/modal"
|
||||||
|
|
||||||
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
const selectValueForActiveDevice = (session: CallSession, kind: DeviceKind): string => {
|
||||||
const livekitDeviceId = session.room.getActiveDevice(kind)
|
const livekitDeviceId = session.livekit.getActiveDevice(kind)
|
||||||
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +52,7 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const session = $currentVoiceSession
|
const session = $currentCallSession
|
||||||
if (!session) {
|
if (!session) {
|
||||||
popModal()
|
popModal()
|
||||||
return
|
return
|
||||||
|
|
@ -58,15 +63,15 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const onInputChange = () => {
|
const onInputChange = () => {
|
||||||
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
|
void switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOutputChange = () => {
|
const onOutputChange = () => {
|
||||||
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
void switchCallActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVideoChange = () => {
|
const onVideoChange = () => {
|
||||||
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
void switchCallActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDone = () => {
|
const onDone = () => {
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,17 @@
|
||||||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
import {makeRoomId} from "@app/groups"
|
import {makeRoomId} from "@app/groups"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
CallState,
|
||||||
currentVoiceRoom,
|
callTargetRoom,
|
||||||
isParticipantSpeaking,
|
isParticipantSpeaking,
|
||||||
mediaStateByIdentity,
|
mediaStateByIdentity,
|
||||||
participantKey,
|
participantKey,
|
||||||
voiceState,
|
callState,
|
||||||
type VoiceParticipant,
|
|
||||||
} from "@app/call/stores"
|
|
||||||
import {
|
|
||||||
cancelJoinVoiceRoom,
|
cancelJoinVoiceRoom,
|
||||||
deriveVoiceParticipants,
|
deriveCallParticipants,
|
||||||
loadVoiceParticipants,
|
loadCallParticipants,
|
||||||
} from "@app/call/voice"
|
type CallParticipant,
|
||||||
|
} from "@app/call"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -36,13 +34,13 @@
|
||||||
|
|
||||||
const {url, h, replaceState = false, notification = false}: Props = $props()
|
const {url, h, replaceState = false, notification = false}: Props = $props()
|
||||||
|
|
||||||
const participants = deriveVoiceParticipants(url, h)
|
const participants = deriveCallParticipants(url, h)
|
||||||
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
||||||
const isActive = $derived(
|
const isActive = $derived(
|
||||||
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
|
$callState === CallState.Connected && $callTargetRoom?.id === makeRoomId(url, h),
|
||||||
)
|
)
|
||||||
const isJoining = $derived(
|
const isJoining = $derived(
|
||||||
$voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h),
|
$callState === CallState.Joining && $callTargetRoom?.id === makeRoomId(url, h),
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = async (e: MouseEvent) => {
|
const handleClick = async (e: MouseEvent) => {
|
||||||
|
|
@ -60,7 +58,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void loadVoiceParticipants(url, h)
|
void loadCallParticipants(url, h)
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -87,8 +85,8 @@
|
||||||
</div>
|
</div>
|
||||||
{#if participantPubkeys.length > 0}
|
{#if participantPubkeys.length > 0}
|
||||||
{#if isActive}
|
{#if isActive}
|
||||||
{#each $participants as p (participantKey(p as VoiceParticipant))}
|
{#each $participants as p (participantKey(p as CallParticipant))}
|
||||||
{@const media = $mediaStateByIdentity(p.identity)}
|
{@const media = $mediaStateByIdentity(p.liveKitIdentity)}
|
||||||
<div class="flex items-center gap-2 ml-6">
|
<div class="flex items-center gap-2 ml-6">
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
import {AbortError, TimeoutError} from "$lib/util"
|
import {AbortError, TimeoutError} from "$lib/util"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {displayRoom} from "@app/groups"
|
import {displayRoom} from "@app/groups"
|
||||||
import {deriveVoiceParticipants, joinVoiceRoom, loadVoiceParticipants} from "@app/call/voice"
|
import {deriveCallParticipants, joinVoiceRoom, loadCallParticipants} from "@app/call"
|
||||||
import {popModal} from "@app/modal"
|
import {popModal} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
const {url, h}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
const spaceLabel = $derived(displayRelayUrl(url))
|
const spaceLabel = $derived(displayRelayUrl(url))
|
||||||
const participants = deriveVoiceParticipants(url, h)
|
const participants = deriveCallParticipants(url, h)
|
||||||
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
||||||
|
|
||||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void loadVoiceParticipants(url, h)
|
void loadCallParticipants(url, h)
|
||||||
void loadDevices()
|
void loadDevices()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,15 @@
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
videoCallLayout,
|
videoCallLayout,
|
||||||
} from "@app/call/video"
|
CallState,
|
||||||
import {
|
currentCallSession,
|
||||||
VoiceState,
|
callTargetRoom,
|
||||||
currentVoiceSession,
|
callMicMuted,
|
||||||
currentVoiceRoom,
|
callState,
|
||||||
voiceMicMuted,
|
cancelJoinVoiceRoom,
|
||||||
voiceState,
|
leaveVoiceRoom,
|
||||||
} from "@app/call/stores"
|
toggleMute,
|
||||||
import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice"
|
} from "@app/call"
|
||||||
|
|
||||||
const {relay, h} = $derived($page.params)
|
const {relay, h} = $derived($page.params)
|
||||||
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
||||||
|
|
@ -49,27 +49,27 @@
|
||||||
const routeDisplayedRoom = $derived($displayedRoomStore)
|
const routeDisplayedRoom = $derived($displayedRoomStore)
|
||||||
|
|
||||||
const isViewingCurrentVoiceRoom = $derived(
|
const isViewingCurrentVoiceRoom = $derived(
|
||||||
$currentVoiceRoom !== undefined &&
|
$callTargetRoom !== undefined &&
|
||||||
url !== undefined &&
|
url !== undefined &&
|
||||||
typeof h === "string" &&
|
typeof h === "string" &&
|
||||||
$currentVoiceRoom.url === url &&
|
$callTargetRoom.url === url &&
|
||||||
$currentVoiceRoom.h === h,
|
$callTargetRoom.h === h,
|
||||||
)
|
)
|
||||||
|
|
||||||
const targetRoom = $derived.by((): Room | undefined => {
|
const targetRoom = $derived.by((): Room | undefined => {
|
||||||
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
if ($callState === CallState.Joining || $callState === CallState.Connected) {
|
||||||
return $currentVoiceRoom
|
return $callTargetRoom
|
||||||
}
|
}
|
||||||
if ($voiceState === VoiceState.Disconnected) {
|
if ($callState === CallState.Disconnected) {
|
||||||
if (routeDisplayedRoom) {
|
if (routeDisplayedRoom) {
|
||||||
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
|
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
|
||||||
return routeDisplayedRoom
|
return routeDisplayedRoom
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return $currentVoiceRoom
|
return $callTargetRoom
|
||||||
}
|
}
|
||||||
return $currentVoiceRoom
|
return $callTargetRoom
|
||||||
})
|
})
|
||||||
|
|
||||||
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
|
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
pushModal(VoiceCallAudioSettingsDialog)
|
pushModal(VoiceCallAudioSettingsDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showChatButton = $derived($voiceState === VoiceState.Connected && isViewingCurrentVoiceRoom)
|
const showChatButton = $derived($callState === CallState.Connected && isViewingCurrentVoiceRoom)
|
||||||
|
|
||||||
const isChatPanelActive = $derived(
|
const isChatPanelActive = $derived(
|
||||||
showChatButton &&
|
showChatButton &&
|
||||||
|
|
@ -134,9 +134,9 @@
|
||||||
onclick={goToRoom}
|
onclick={goToRoom}
|
||||||
aria-label="Open room {roomName}">
|
aria-label="Open room {roomName}">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
{#if $callState === CallState.Joining}
|
||||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
{:else if $voiceState === VoiceState.Connected}
|
{:else if $callState === CallState.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>
|
||||||
|
|
@ -168,7 +168,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
{#if $callState === CallState.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"
|
||||||
|
|
@ -176,18 +176,18 @@
|
||||||
onclick={cancelJoinVoiceRoom}>
|
onclick={cancelJoinVoiceRoom}>
|
||||||
<Icon icon={CloseCircle} size={4} />
|
<Icon icon={CloseCircle} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
{:else if $callState === CallState.Connected && $currentCallSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$voiceMicMuted ? "Unmute" : "Mute"}
|
data-tip={$callMicMuted ? "Unmute" : "Mute"}
|
||||||
class={cx(
|
class={cx(
|
||||||
mediaToggleClass,
|
mediaToggleClass,
|
||||||
"overflow-visible",
|
"overflow-visible",
|
||||||
$voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
$callMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||||
)}
|
)}
|
||||||
onclick={toggleMute}>
|
onclick={toggleMute}>
|
||||||
<span class="relative inline-flex items-center justify-center overflow-visible">
|
<span class="relative inline-flex items-center justify-center overflow-visible">
|
||||||
<Icon icon={Microphone} size={4} />
|
<Icon icon={Microphone} size={4} />
|
||||||
{#if $voiceMicMuted}
|
{#if $callMicMuted}
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
|
|
@ -199,23 +199,21 @@
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
data-tip={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||||
class={cx(
|
class={cx(
|
||||||
mediaToggleClass,
|
mediaToggleClass,
|
||||||
"overflow-visible",
|
"overflow-visible",
|
||||||
$currentVoiceSession.cameraOn && "text-primary",
|
$currentCallSession.cameraOn && "text-primary",
|
||||||
!$currentVoiceSession.cameraOn &&
|
!$currentCallSession.cameraOn &&
|
||||||
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||||
)}
|
)}
|
||||||
onclick={toggleCamera}>
|
onclick={toggleCamera}>
|
||||||
<Icon
|
<Icon icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff} size={4} />
|
||||||
icon={$currentVoiceSession.cameraOn ? VideocameraRecord : VideocameraOff}
|
|
||||||
size={4} />
|
|
||||||
</Button>
|
</Button>
|
||||||
{#if !Capacitor.isNativePlatform()}
|
{#if !Capacitor.isNativePlatform()}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
data-tip={$currentCallSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
||||||
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
|
class={cx(mediaToggleClass, $currentCallSession.screenShareOn && "text-primary")}
|
||||||
onclick={toggleScreenShare}>
|
onclick={toggleScreenShare}>
|
||||||
<Icon icon={Monitor} size={4} />
|
<Icon icon={Monitor} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
275
src/app/rooms.ts
275
src/app/rooms.ts
|
|
@ -1,275 +0,0 @@
|
||||||
import {
|
|
||||||
ROOMS,
|
|
||||||
ROOM_DELETE,
|
|
||||||
ROOM_META,
|
|
||||||
asDecryptedEvent,
|
|
||||||
getGroupTags,
|
|
||||||
getListTags,
|
|
||||||
getRelayTagValues,
|
|
||||||
getTagValues,
|
|
||||||
isRelayUrl,
|
|
||||||
makeRoomMeta,
|
|
||||||
normalizeRelayUrl,
|
|
||||||
readList,
|
|
||||||
readRoomMeta,
|
|
||||||
} from "@welshman/util"
|
|
||||||
import type {List, PublishedRoomMeta, RelayProfile, RoomMeta, TrustedEvent} from "@welshman/util"
|
|
||||||
import {
|
|
||||||
deriveEventsByIdByUrl,
|
|
||||||
deriveItemsByKey,
|
|
||||||
getter,
|
|
||||||
makeDeriveItem,
|
|
||||||
makeLoadItem,
|
|
||||||
} from "@welshman/store"
|
|
||||||
import {makeOutboxLoader, makeUserData, makeUserLoader, repository, tracker} from "@welshman/app"
|
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {
|
|
||||||
addToMapKey,
|
|
||||||
call,
|
|
||||||
gte,
|
|
||||||
identity,
|
|
||||||
indexBy,
|
|
||||||
max,
|
|
||||||
partition,
|
|
||||||
sortBy,
|
|
||||||
spec,
|
|
||||||
tryCatch,
|
|
||||||
uniq,
|
|
||||||
uniqBy,
|
|
||||||
} from "@welshman/lib"
|
|
||||||
import {load} from "@welshman/net"
|
|
||||||
|
|
||||||
export const ROOM = "h"
|
|
||||||
|
|
||||||
export const PROTECTED = ["-"]
|
|
||||||
|
|
||||||
export enum RoomType {
|
|
||||||
Text = "text",
|
|
||||||
Voice = "voice",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Room = PublishedRoomMeta & {
|
|
||||||
id: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRoomType = (room: RoomMeta): RoomType =>
|
|
||||||
room.livekit ? RoomType.Voice : RoomType.Text
|
|
||||||
|
|
||||||
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
|
|
||||||
|
|
||||||
export const isRoomId = (id: string) => id.includes("'")
|
|
||||||
|
|
||||||
export const splitRoomId = (id: string) => id.split("'")
|
|
||||||
|
|
||||||
export const hasNip29 = (relay?: RelayProfile) =>
|
|
||||||
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("29"))
|
|
||||||
|
|
||||||
export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
|
|
||||||
tracker,
|
|
||||||
repository,
|
|
||||||
filters: [{kinds: [ROOM_META, ROOM_DELETE]}],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
|
|
||||||
const result = new Map<string, Room[]>()
|
|
||||||
|
|
||||||
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
|
|
||||||
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
|
|
||||||
const deletedByH = new Map<string, number>()
|
|
||||||
|
|
||||||
for (const event of deleteEvents) {
|
|
||||||
for (const h of getTagValues("h", event.tags)) {
|
|
||||||
deletedByH.set(h, max([deletedByH.get(h), event.created_at]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaById = new Map<string, Room>()
|
|
||||||
|
|
||||||
for (const event of metaEvents) {
|
|
||||||
const meta = tryCatch(() => readRoomMeta(event))
|
|
||||||
|
|
||||||
if (!meta || gte(deletedByH.get(meta.h), meta.event.created_at)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = makeRoomId(url, meta.h)
|
|
||||||
|
|
||||||
metaById.set(id, {...meta, url, id})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metaById.size > 0) {
|
|
||||||
result.set(url, Array.from(metaById.values()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
export const roomsById = derived(roomsByUrl, roomsByUrl =>
|
|
||||||
indexBy(room => room.id, Array.from(roomsByUrl.values()).flatMap(identity)),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const getRoomsById = getter(roomsById)
|
|
||||||
|
|
||||||
export const getRoom = (id: string) => getRoomsById().get(id)
|
|
||||||
|
|
||||||
export const loadRoom = call(() => {
|
|
||||||
const _fetchRoom = async (id: string) => {
|
|
||||||
const [url, h] = splitRoomId(id)
|
|
||||||
|
|
||||||
await load({
|
|
||||||
relays: [url],
|
|
||||||
filters: [
|
|
||||||
{kinds: [ROOM_META], "#d": [h]},
|
|
||||||
{kinds: [ROOM_DELETE], "#h": [h]},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const _loadRoom = makeLoadItem(_fetchRoom, getRoom)
|
|
||||||
|
|
||||||
return (url: string, h: string) => _loadRoom(makeRoomId(url, h))
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveRoom = call(() => {
|
|
||||||
const _deriveRoom = makeDeriveItem(roomsById, loadRoom)
|
|
||||||
|
|
||||||
return (url: string, h: string) =>
|
|
||||||
derived(
|
|
||||||
_deriveRoom(makeRoomId(url, h)),
|
|
||||||
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h))?.name || h
|
|
||||||
|
|
||||||
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
|
|
||||||
|
|
||||||
export const deriveVoiceRooms = (url: string) =>
|
|
||||||
derived(roomsById, $roomsById => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
for (const room of $roomsById.values()) {
|
|
||||||
if (room.url === url && room.livekit) {
|
|
||||||
set.add(room.h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveOtherVoiceRooms = (url: string) =>
|
|
||||||
derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => {
|
|
||||||
const rooms: string[] = []
|
|
||||||
|
|
||||||
for (const h of $roomsWithLivekit) {
|
|
||||||
if (!$userRooms.includes(h)) {
|
|
||||||
rooms.push(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(roomComparator(url), uniq(rooms))
|
|
||||||
})
|
|
||||||
|
|
||||||
// User space/room lists
|
|
||||||
|
|
||||||
export const groupListsByPubkey = deriveItemsByKey({
|
|
||||||
repository,
|
|
||||||
filters: [{kinds: [ROOMS]}],
|
|
||||||
getKey: list => list.event.pubkey,
|
|
||||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getGroupListsByPubkey = getter(groupListsByPubkey)
|
|
||||||
|
|
||||||
export const getGroupList = (pubkey: string) => getGroupListsByPubkey().get(pubkey)
|
|
||||||
|
|
||||||
export const loadGroupList = makeLoadItem(makeOutboxLoader(ROOMS), getGroupList)
|
|
||||||
|
|
||||||
export const deriveGroupList = makeDeriveItem(groupListsByPubkey, loadGroupList)
|
|
||||||
|
|
||||||
export const groupListPubkeysByUrl = derived(groupListsByPubkey, $groupListsByPubkey => {
|
|
||||||
const result = new Map<string, Set<string>>()
|
|
||||||
|
|
||||||
for (const list of $groupListsByPubkey.values()) {
|
|
||||||
const tags = getListTags(list)
|
|
||||||
|
|
||||||
for (const url of getRelayTagValues(tags)) {
|
|
||||||
addToMapKey(result, url, list.event.pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tag of getGroupTags(tags)) {
|
|
||||||
const url = tag[2] || ""
|
|
||||||
|
|
||||||
if (isRelayUrl(url)) {
|
|
||||||
addToMapKey(result, url, list.event.pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveGroupListPubkeys = (url: string) =>
|
|
||||||
derived(groupListPubkeysByUrl, $groupListPubkeysByUrl => new Set($groupListPubkeysByUrl.get(url)))
|
|
||||||
|
|
||||||
export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => {
|
|
||||||
const tags = getListTags(groupList)
|
|
||||||
const urls = getRelayTagValues(tags)
|
|
||||||
|
|
||||||
for (const tag of getGroupTags(tags)) {
|
|
||||||
const url = tag[2] || ""
|
|
||||||
|
|
||||||
if (isRelayUrl(url)) {
|
|
||||||
urls.push(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqBy(normalizeRelayUrl, urls)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => {
|
|
||||||
const rooms: string[] = []
|
|
||||||
|
|
||||||
for (const [_, h, relay] of getGroupTags(getListTags(groupList))) {
|
|
||||||
if (url === relay) {
|
|
||||||
rooms.push(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(roomComparator(url), uniq(rooms))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList)
|
|
||||||
|
|
||||||
export const loadUserGroupList = makeUserLoader(loadGroupList)
|
|
||||||
|
|
||||||
export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList)
|
|
||||||
|
|
||||||
export const deriveUserRooms = (url: string) =>
|
|
||||||
derived([userGroupList, roomsById], ([$userGroupList, $roomsById]) => {
|
|
||||||
const rooms: string[] = []
|
|
||||||
|
|
||||||
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
|
|
||||||
if ($roomsById.has(makeRoomId(url, h))) {
|
|
||||||
rooms.push(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(roomComparator(url), rooms)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const deriveOtherRooms = (url: string) =>
|
|
||||||
derived(
|
|
||||||
[deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
|
|
||||||
([$userRooms, voiceRooms, $roomsByUrl]) => {
|
|
||||||
const rooms: string[] = []
|
|
||||||
|
|
||||||
for (const {h} of $roomsByUrl.get(url) || []) {
|
|
||||||
if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
|
|
||||||
rooms.push(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(roomComparator(url), uniq(rooms))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -57,7 +57,7 @@ import {loadFeedsForPubkey} from "@app/feeds"
|
||||||
import {RELAY_ROLE} from "@app/members"
|
import {RELAY_ROLE} from "@app/members"
|
||||||
import {FEATURED_CONTENT_D} from "@app/featured"
|
import {FEATURED_CONTENT_D} from "@app/featured"
|
||||||
import {hasBlossomSupport} from "@app/uploads"
|
import {hasBlossomSupport} from "@app/uploads"
|
||||||
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
import {LIVEKIT_PARTICIPANTS} from "@app/call"
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,14 @@
|
||||||
import {userSettingsValues} from "@app/settings"
|
import {userSettingsValues} from "@app/settings"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
||||||
import {VoiceState, currentVoiceRoom, voiceState} from "@app/call/stores"
|
import {
|
||||||
import {VideoCallLayout, videoCallLayout, videoTileCount} from "@app/call/video"
|
CallState,
|
||||||
|
callTargetRoom,
|
||||||
|
callState,
|
||||||
|
VideoCallLayout,
|
||||||
|
videoCallLayout,
|
||||||
|
videoTileCount,
|
||||||
|
} from "@app/call"
|
||||||
import {makeFeed} from "@app/feeds"
|
import {makeFeed} from "@app/feeds"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
|
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
|
||||||
|
|
@ -59,15 +65,13 @@
|
||||||
|
|
||||||
const voiceConnectedHere = $derived(
|
const voiceConnectedHere = $derived(
|
||||||
isVoiceRoom &&
|
isVoiceRoom &&
|
||||||
$voiceState === VoiceState.Connected &&
|
$callState === CallState.Connected &&
|
||||||
$currentVoiceRoom?.url === url &&
|
$callTargetRoom?.url === url &&
|
||||||
$currentVoiceRoom?.h === h,
|
$callTargetRoom?.h === h,
|
||||||
)
|
)
|
||||||
|
|
||||||
const showMobileVideoPanel = $derived(
|
const showMobileVideoPanel = $derived(
|
||||||
isVoiceRoom &&
|
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
|
||||||
$voiceState === VoiceState.Connected &&
|
|
||||||
$videoCallLayout === VideoCallLayout.Video,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const pageContentHiddenDesktopVideoOnly = $derived(
|
const pageContentHiddenDesktopVideoOnly = $derived(
|
||||||
|
|
@ -92,13 +96,13 @@
|
||||||
let prevVideoTileCount = $state(0)
|
let prevVideoTileCount = $state(0)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($voiceState !== VoiceState.Connected) {
|
if ($callState !== CallState.Connected) {
|
||||||
videoCallLayout.set(VideoCallLayout.Chat)
|
videoCallLayout.set(VideoCallLayout.Chat)
|
||||||
prevVideoTileCount = 0
|
prevVideoTileCount = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h
|
const here = isVoiceRoom && $callTargetRoom?.url === url && $callTargetRoom?.h === h
|
||||||
const n = $videoTileCount
|
const n = $videoTileCount
|
||||||
|
|
||||||
if (!here) {
|
if (!here) {
|
||||||
|
|
@ -455,7 +459,7 @@
|
||||||
"flex min-h-0 min-w-0 flex-1 flex-col",
|
"flex min-h-0 min-w-0 flex-1 flex-col",
|
||||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
||||||
)}>
|
)}>
|
||||||
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
|
{#if isVoiceRoom && $callState === CallState.Connected}
|
||||||
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -584,7 +588,7 @@
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
{#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue