From fd58de5de4606cabae428f44cdd15b2ad3c50657 Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Thu, 21 May 2026 16:43:42 +0530 Subject: [PATCH] chore: show call participant mute and camera-off state --- src/app/call/stores.ts | 17 +++++- src/app/call/voice.ts | 55 +++++++++++-------- src/app/components/VideoCallContent.svelte | 43 ++++++++++++++- .../VoiceParticipantMediaBadges.svelte | 34 ++++++++++++ src/app/components/VoiceRoomItem.svelte | 12 +++- src/app/components/VoiceWidget.svelte | 16 ++++-- src/assets/icons/videocamera-off.svg | 5 ++ 7 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 src/app/components/VoiceParticipantMediaBadges.svelte create mode 100644 src/assets/icons/videocamera-off.svg diff --git a/src/app/call/stores.ts b/src/app/call/stores.ts index 9b5fbac0..160bc15c 100644 --- a/src/app/call/stores.ts +++ b/src/app/call/stores.ts @@ -29,8 +29,6 @@ export const voiceState = writable(VoiceState.Disconnected) export const currentVoiceRoom = writable(undefined) -export const participantPubkeyMap = writable>(new Map()) - export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined => /^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined @@ -43,6 +41,21 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity export const speakingParticipants = writable([]) +export const participantMediaState = writable( + new Map(), +) + +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) => diff --git a/src/app/call/voice.ts b/src/app/call/voice.ts index 050e2385..1ec405e0 100644 --- a/src/app/call/voice.ts +++ b/src/app/call/voice.ts @@ -6,14 +6,16 @@ import { DisconnectReason, LocalParticipant, LocalTrackPublication, + Participant, Room as LiveKitRoom, RoomEvent, Track, + TrackPublication, supportsAudioOutputSelection, type AudioCaptureOptions, } from "livekit-client" import {derived, get} from "svelte/store" -import {map, not, removeUndefined, uniqBy} from "@welshman/lib" +import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {signer} from "@welshman/app" @@ -25,8 +27,7 @@ import { voiceMicMuted, participantFromLiveKitIdentity, participantKey, - participantPubkeyMap, - pubkeyFromLiveKitIdentity, + participantMediaState, speakingParticipants, VoiceState, type VoiceParticipant, @@ -76,20 +77,23 @@ export const switchVoiceActiveDevice = async ( } } -const addParticipant = (identity: string) => { - participantPubkeyMap.update(m => { +const deleteParticipant = (identity: string) => { + participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m]))) +} + +const syncParticipantMedia = (participant: Participant) => { + const state = {muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled} + 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(identity, pubkeyFromLiveKitIdentity(identity) ?? "") + next.set(participant.identity, state) return next }) } -const deleteParticipant = (identity: string) => { - participantPubkeyMap.update(m => { - const next = new Map(m) - next.delete(identity) - return next - }) +const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => { + syncParticipantMedia(participant) } const fetchLivekitToken = async ( @@ -125,15 +129,15 @@ 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( [ - participantPubkeyMap, + participantMediaState, currentVoiceRoom, deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), ], - ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { - const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h) + ([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => { + const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h) if (inCall) { - const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) + const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity) return uniqBy((p: VoiceParticipant) => participantKey(p), participants) } else { const latestEvent = $publishedParticipantList as TrustedEvent | undefined @@ -186,7 +190,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => { pushToast({theme: "error", message}) } speakingParticipants.set([]) - participantPubkeyMap.set(new Map()) + participantMediaState.set(new Map()) } const onTrackSubscribed = (track: Track) => { @@ -216,8 +220,8 @@ const playJoinSound = () => { audio.play().catch(() => {}) } -const onParticipantConnected = (participant: {identity: string}) => { - addParticipant(participant.identity) +const onParticipantConnected = (participant: Participant) => { + syncParticipantMedia(participant) playJoinSound() } @@ -275,6 +279,11 @@ export const joinVoiceRoom = async ( liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished) liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged) + liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged) + liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged) + liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged) + liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged) try { await Promise.race([ @@ -289,10 +298,10 @@ export const joinVoiceRoom = async ( throw e } - participantPubkeyMap.set(new Map()) - addParticipant(liveKitRoom.localParticipant.identity) + participantMediaState.set(new Map()) + syncParticipantMedia(liveKitRoom.localParticipant) for (const p of liveKitRoom.remoteParticipants.values()) { - addParticipant(p.identity) + syncParticipantMedia(p) } const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant) @@ -346,7 +355,7 @@ export const leaveVoiceRoom = async () => { resetVideoCallLayout() session.room.disconnect() speakingParticipants.set([]) - participantPubkeyMap.set(new Map()) + participantMediaState.set(new Map()) } export const rejoinVoiceRoom = async (): Promise => { diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index 6bd00ebf..d44b0240 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -8,6 +8,7 @@ import ProfileCircle from "@app/components/ProfileCircle.svelte" import VideoCallTile from "@app/components/VideoCallTile.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte" + import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte" import {get} from "svelte/store" import { VideoCallLayout, @@ -18,7 +19,12 @@ ViewportSize, videoPrimaryTileKey, } from "@app/call/video" - import {currentVoiceSession, currentVoiceRoom, pubkeyFromLiveKitIdentity} from "@app/call/stores" + import { + currentVoiceSession, + currentVoiceRoom, + mediaStateByIdentity, + pubkeyFromLiveKitIdentity, + } from "@app/call/stores" type Props = { layout: VideoCallLayout @@ -121,6 +127,25 @@ source: Track.Source.ScreenShare, }) } + if (!videoTiles.some(t => t.identity === rp.identity)) { + videoTiles.push({ + identity: rp.identity, + isLocal: false, + trackSid: `avatar-${rp.identity}`, + track: undefined, + source: Track.Source.Camera, + }) + } + } + + if (!videoTiles.some(t => t.identity === user.identity)) { + videoTiles.push({ + identity: user.identity, + isLocal: true, + trackSid: "local-avatar", + track: undefined, + source: Track.Source.Camera, + }) } return videoTiles @@ -187,6 +212,7 @@ {#snippet videoTile(tile: VideoTileData, layout: TileLayout)} + {@const media = $mediaStateByIdentity(tile.identity)}
{/if} + {#if tile.track} +
+ +
+ {/if} {labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""} @@ -256,8 +291,10 @@ {:else}
-

No camera or screen share yet.

-

Use the camera or screen share control to share video.

+

No one is sharing video yet.

+

+ Participants appear here when they turn on their camera or share their screen. +

{/if} {/snippet} diff --git a/src/app/components/VoiceParticipantMediaBadges.svelte b/src/app/components/VoiceParticipantMediaBadges.svelte new file mode 100644 index 00000000..2344db51 --- /dev/null +++ b/src/app/components/VoiceParticipantMediaBadges.svelte @@ -0,0 +1,34 @@ + + +{#if muted || (showCamera && !cameraOn)} +
+ {#if muted} + + + + {/if} + {#if showCamera && !cameraOn} + + + + {/if} +
+{/if} diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 965d1ba4..bf04855d 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -9,11 +9,13 @@ import {makeRoomPath} from "@app/util/routes" import {pushModal} from "@app/util/modal" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" + import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte" import {makeRoomId} from "@app/core/state" import { VoiceState, currentVoiceRoom, isParticipantSpeaking, + mediaStateByIdentity, participantKey, voiceState, type VoiceParticipant, @@ -83,9 +85,17 @@ )}> - + {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"} + {#if isActive} + {@const media = $mediaStateByIdentity(p.identity)} + + {/if} {/each} {/if} diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 1882b3f8..195b36be 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -6,7 +6,7 @@ import cx from "classnames" import {displayRelayUrl} from "@welshman/util" import Microphone from "@assets/icons/microphone.svg?dataurl" - import Videocamera from "@assets/icons/videocamera.svg?dataurl" + import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl" import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl" import Monitor from "@assets/icons/monitor.svg?dataurl" import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl" @@ -43,7 +43,6 @@ currentVoiceRoom, voiceMicMuted, voiceState, - isLocalSpeaking, } from "@app/call/stores" import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice" @@ -188,7 +187,6 @@ class={cx( mediaToggleClass, "overflow-visible", - !$voiceMicMuted && $isLocalSpeaking && "text-primary", $voiceMicMuted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100", )} onclick={toggleMute}> @@ -207,9 +205,17 @@ {#if !Capacitor.isNativePlatform()}