components-js icon indicating copy to clipboard operation
components-js copied to clipboard

Moderator Controls

Open dmke opened this issue 1 year ago • 0 comments

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:

image

Importance

would make my life easier

Additional Information

No response

dmke avatar Jan 11 '24 10:01 dmke