diff --git a/src/app/call/stores.ts b/src/app/call/stores.ts index abe4084e..75ce8212 100644 --- a/src/app/call/stores.ts +++ b/src/app/call/stores.ts @@ -41,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], + ([$media, $session]) => + (identity: string) => { + if ($session?.room.localParticipant.identity === identity) { + return {muted: $session.muted, 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 07ab0648..bb72b080 100644 --- a/src/app/call/voice.ts +++ b/src/app/call/voice.ts @@ -6,9 +6,11 @@ import { DisconnectReason, LocalParticipant, LocalTrackPublication, + Participant, Room as LiveKitRoom, RoomEvent, Track, + TrackPublication, supportsAudioOutputSelection, type AudioCaptureOptions, } from "livekit-client" @@ -24,6 +26,7 @@ import { currentVoiceSession, participantFromLiveKitIdentity, participantKey, + participantMediaState, participantPubkeyMap, pubkeyFromLiveKitIdentity, speakingParticipants, @@ -89,6 +92,27 @@ const deleteParticipant = (identity: string) => { next.delete(identity) return next }) + participantMediaState.update(m => { + if (!m.has(identity)) return m + const next = new Map(m) + next.delete(identity) + return next + }) +} + +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(participant.identity, state) + return next + }) +} + +const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => { + syncParticipantMedia(participant) } const fetchLivekitToken = async ( @@ -185,6 +209,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => { } speakingParticipants.set([]) participantPubkeyMap.set(new Map()) + participantMediaState.set(new Map()) } const onTrackSubscribed = (track: Track) => { @@ -214,8 +239,9 @@ const playJoinSound = () => { audio.play().catch(() => {}) } -const onParticipantConnected = (participant: {identity: string}) => { +const onParticipantConnected = (participant: Participant) => { addParticipant(participant.identity) + syncParticipantMedia(participant) playJoinSound() } @@ -273,6 +299,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([ @@ -288,9 +319,12 @@ export const joinVoiceRoom = async ( } participantPubkeyMap.set(new Map()) + participantMediaState.set(new Map()) addParticipant(liveKitRoom.localParticipant.identity) + syncParticipantMedia(liveKitRoom.localParticipant) for (const p of liveKitRoom.remoteParticipants.values()) { addParticipant(p.identity) + syncParticipantMedia(p) } const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant) @@ -344,6 +378,7 @@ export const leaveVoiceRoom = async () => { 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 ce65a7ed..f8ff42dd 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 @@ -123,6 +129,28 @@ } } + const tiledIdentities = new Set(videoTiles.map(t => t.identity)) + if (!tiledIdentities.has(user.identity)) { + videoTiles.push({ + identity: user.identity, + isLocal: true, + trackSid: "local-avatar", + track: undefined, + source: Track.Source.Camera, + }) + } + for (const rp of room.remoteParticipants.values()) { + if (!tiledIdentities.has(rp.identity)) { + videoTiles.push({ + identity: rp.identity, + isLocal: false, + trackSid: `avatar-${rp.identity}`, + track: undefined, + source: Track.Source.Camera, + }) + } + } + return videoTiles }) @@ -184,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)" : ""} @@ -254,8 +292,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 c8601644..7c608dc0 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -6,7 +6,8 @@ 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 MicrophoneOff from "@assets/icons/microphone-off.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" @@ -37,13 +38,7 @@ toggleScreenShare, videoCallLayout, } from "@app/call/video" - import { - VoiceState, - currentVoiceSession, - currentVoiceRoom, - voiceState, - isLocalSpeaking, - } from "@app/call/stores" + import {VoiceState, currentVoiceSession, currentVoiceRoom, voiceState} from "@app/call/stores" import {cancelJoinVoiceRoom, leaveVoiceRoom, toggleMute} from "@app/call/voice" const {relay, h} = $derived($page.params) @@ -187,29 +182,26 @@ class={cx( mediaToggleClass, "overflow-visible", - !$currentVoiceSession.muted && $isLocalSpeaking && "text-primary", + !$currentVoiceSession.muted && "text-primary", $currentVoiceSession.muted && "text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100", )} onclick={toggleMute}> - - - {#if $currentVoiceSession.muted} - - {/if} - + {#if !Capacitor.isNativePlatform()}