components-js
components-js copied to clipboard
Moderator Controls
Describe the problem
When a participant has roomAdmin
permissions, that participant should be able to mute the audio and/or video track of other participants, or even remove them from the room.
Describe the proposed solution
Given that <LiveKitRoom/>
already receives a token with embedded grants (VideoGrant.roomAdmin
), there shouldn't be much need for configuration:
<LiveKitRoom token={token} {...etc}>
<VideoConference />
</LiveKitRoom>
When that grant is present, the <ParticipantTile/>
should provide either additional buttons next to the <FocusToggle/>
, or add them to the .lk-participant-metadata
(e.g. turning the <TrackMutedIndicator/>
into a button).
I'm not entirely sure as whether the JS client is allowed to issue commands directly to the LiveKit server, but if not, the <LiveKitRoom/>
or <VideoConference/>
should provide callback options:
async function muteParticipant(p: Participant, trackRef: TrackPublication) {
// use custom API client and instruct server to perform participant muting
await myApi.rooms(room).participants(p.identity).mute(trackRef.trackSid)
}
async function removeParticipant(p: Participant) {
// use custom API client and instruct server to remove participant from room
await myApi.rooms(room).participants(p.identity).remove()
}
<LiveKitRoom room={room} token={token} {...etc}>
<VideoConference
onParticipantMute={muteParticipant}
onParticipantRemove={removeParticipant}
/>
</LiveKitRoom>
Alternatives considered
I currently have a bit of a "hacky" implementation:
return <LiveKitRoom token={props.token.token} serverUrl={props.token.url} ...etc>
<VideoConference />
{isAdmin && <AdminControls/>}
</LiveKitRoom>
with the following implementations of
isAdmin
This basically looks for the roomAdmin
grant in the JWT token:
const isAdmin = useMemo(() => {
const tok = decodeJwt<{ video: { roomAdmin?: boolean } }>(props.token.token)
return tok.video.roomAdmin ?? false
}, [props.token])
AdminControls.tsx
Note: ./bootstrap
and ./icons/*
provide some very basic Bootstrap components (e.g. <BS.Button />
) and FontAwesome icons (e.g. <FaCircleXmark/>
). Those are custom implementations to keep the bundle size smaller.
../lib/api
is a client for the authentication server (my setup has a Rails application handling user logins and permission assignment). Among others, it provides a proxy to RoomServiceClient#mute_published_track
and RoomServiceClient#remove_participant
.
import { useParticipants, useRoomInfo } from "@livekit/components-react"
import { LocalParticipant, RemoteParticipant, RoomEvent } from "livekit-client"
import { useState } from "react"
import { api } from "../lib/api"
import BS from "./bootstrap"
import { FaCircleXmark } from "./icons/FaCircleXmark.tsx"
import { FaMicrophoneSlash } from "./icons/FaMicrophoneSlash.tsx"
import { FaUsersGear } from "./icons/FaUsersGear.tsx"
import { FaVideoSlash } from "./icons/FaVideoSlash.tsx"
interface ToggleProps {
room: string
p: RemoteParticipant | LocalParticipant
}
function MicToggle({ room, p }: ToggleProps) {
function mute() {
for (const sid of p.audioTracks.keys()) {
api.rooms.mute({
room_name: room,
identity: p.identity,
track: sid,
})
}
}
return p.isMicrophoneEnabled
? <BS.Button size="sm" variant="light" onClick={mute}>
<FaMicrophoneSlash/>
</BS.Button>
: <BS.Button size="sm" variant="dark" disabled>
<FaMicrophoneSlash className="text-muted" />
</BS.Button>
}
function CamToggle({ room, p }: ToggleProps) {
function mute() {
for (const sid of p.videoTracks.keys()) {
api.rooms.mute({
room_name: room,
identity: p.identity,
track: sid,
})
}
}
return p.isCameraEnabled
? <BS.Button size="sm" variant="light" onClick={mute}>
<FaVideoSlash/>
</BS.Button>
: <BS.Button size="sm" variant="dark" disabled>
<FaVideoSlash className="text-muted" />
</BS.Button>
}
function KickButton({ room, p }: ToggleProps) {
function kick() {
api.rooms.kick({
room_name: room,
identity: p.identity,
})
}
return <BS.Button size="sm" variant="dark" onClick={kick}>
<FaCircleXmark className="text-danger"/>
</BS.Button>
}
export function AdminControls() {
const room = useRoomInfo()
const pcts = useParticipants({
updateOnlyOn: [
RoomEvent.Connected,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
],
})
const [open, setOpen] = useState(false)
return <div className="admin-controls">
{open && <div className="popover show">
<div className="popover-body">
<table className="table table-sm">
<tbody>
{pcts.map(p => <tr key={p.sid}>
<td className="pe-3">
{p.name || <em>unbekannt</em>}
</td>
<td className="text-center">
<MicToggle room={room.name} p={p} />
</td>
<td className="text-center">
<CamToggle room={room.name} p={p} />
</td>
<td className="text-center">
<KickButton room={room.name} p={p} />
</td>
</tr>)}
</tbody>
</table>
</div>
</div>}
<BS.Button
id="toggle-user-control"
variant="dark"
size="lg"
onClick={() => setOpen(cur => !cur)}
>
<FaUsersGear/>
</BS.Button>
</div>
}
With some additional CSS (absolute positioning of the AdminControl's container), this renders as:
Importance
would make my life easier
Additional Information
No response