chore: show call participant mute and camera-off state (#279)

Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
This commit is contained in:
2026-05-21 20:58:53 +00:00
committed by hodlbod
parent ffd06ab561
commit 0d61278c56
7 changed files with 148 additions and 34 deletions
+15 -2
View File
@@ -29,8 +29,6 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(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<VoiceParticipant[]>([])
export const participantMediaState = writable(
new Map<string, {muted: boolean; cameraOn: boolean}>(),
)
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) =>
+32 -23
View File
@@ -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<void> => {
+40 -3
View File
@@ -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 @@
</script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayout)}
{@const media = $mediaStateByIdentity(tile.identity)}
<div
class={cx(
"relative isolate overflow-hidden rounded-box shadow-sm",
@@ -206,6 +232,15 @@
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
</div>
{/if}
{#if tile.track}
<div class="pointer-events-none absolute left-1 top-1 z-10">
<VoiceParticipantMediaBadges
muted={media.muted}
cameraOn={media.cameraOn}
showCamera={tile.source === Track.Source.Camera}
size={3} />
</div>
{/if}
<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">
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
@@ -256,8 +291,10 @@
{:else}
<div
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
<p>No camera or screen share yet.</p>
<p class="text-xs">Use the camera or screen share control to share video.</p>
<p>No one is sharing video yet.</p>
<p class="text-xs">
Participants appear here when they turn on their camera or share their screen.
</p>
</div>
{/if}
{/snippet}
@@ -0,0 +1,34 @@
<script lang="ts">
import cx from "classnames"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import VideocameraOff from "@assets/icons/videocamera-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
type Props = {
muted: boolean
cameraOn: boolean
showCamera?: boolean
size?: number
class?: string
}
const {muted, cameraOn, showCamera = true, size = 3, class: className = ""}: Props = $props()
const badgeClass =
"inline-flex size-4 shrink-0 items-center justify-center rounded bg-base-100/80 p-0.5 text-error"
</script>
{#if muted || (showCamera && !cameraOn)}
<div class={cx("flex items-center gap-1", className)}>
{#if muted}
<span class={badgeClass} aria-label="Muted">
<Icon icon={MicrophoneOff} {size} />
</span>
{/if}
{#if showCamera && !cameraOn}
<span class={badgeClass} aria-label="Camera off">
<Icon icon={VideocameraOff} {size} />
</span>
{/if}
</div>
{/if}
+11 -1
View File
@@ -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 @@
)}>
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
</div>
<span class="ellipsize text-xs opacity-70">
<span class="ellipsize min-w-0 flex-1 text-xs opacity-70">
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
</span>
{#if isActive}
{@const media = $mediaStateByIdentity(p.identity)}
<VoiceParticipantMediaBadges
muted={media.muted}
cameraOn={media.cameraOn}
size={3}
class="shrink-0" />
{/if}
</div>
{/each}
{/if}
+11 -5
View File
@@ -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 @@
</Button>
<Button
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
class={cx(
mediaToggleClass,
"overflow-visible",
$currentVoiceSession.cameraOn && "text-primary",
!$currentVoiceSession.cameraOn &&
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
)}
onclick={toggleCamera}>
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
<Icon
icon={$currentVoiceSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={4} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button