Kamera i ekran dla konkretnego uczestnika z panelu prowadzenia
Deploy PROD / deploy (push) Successful in 26s

Karta "Na sali" dostala przelaczniki kamery i udostepniania ekranu przy
kazdym uczestniku (wylacznie LiveKit). Uprawnienia licza sie z trzech
niezaleznych praw (PublishRights: mikrofon, kamera, ekran) zamiast jednej
roli, w jednym miejscu dla tokenu i zmiany na zywo; serwer pokoju nadaje
i odbiera je bez ponownego wejscia na sale.

Kolumny camera_allowed i screen_allowed (V32), domyslnie false. Zgoda
indywidualna przezywa wylaczenie "wideo dla wszystkich". Uczestnik
dostaje komunikat, a przy ekranie ostrzezenie, ze sala zobaczy wszystko.

PublishRightsTest i PublishRightsTokenTest pilnuja, ze zgoda na jedno
zrodlo nie otwiera innego; oba sprawdzone w obie strony.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:59:00 +02:00
parent dda54f00a1
commit 589c317841
17 changed files with 603 additions and 100 deletions
+10 -3
View File
@@ -81,10 +81,16 @@ type History = {
questions: InteractionView[]
}
/** Zdarzenie moderacji uczestnika: wyciszenie/blokada. */
/**
* Zdarzenie dotyczace jednego uczestnika: wyciszenie, blokada albo zmiana
* indywidualnej zgody na kamere i ekran (`PUBLISH_CHANGED`).
*/
export type ModerationEvent = {
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED'
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
registrationId: number
/** Tylko przy PUBLISH_CHANGED. */
camera?: boolean
screen?: boolean
}
type Event = { kind: string; payload: InteractionView }
@@ -245,7 +251,8 @@ export function useInteractions(
}
if (
event.kind === 'MUTED' || event.kind === 'UNMUTED' ||
event.kind === 'BANNED' || event.kind === 'UNBANNED'
event.kind === 'BANNED' || event.kind === 'UNBANNED' ||
event.kind === 'PUBLISH_CHANGED'
) {
const mod = event as unknown as ModerationEvent
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
+23
View File
@@ -75,6 +75,8 @@ type JoinView = {
*/
canPublishVideo: boolean
canPublishAudio: boolean
/** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
canShareScreen: boolean
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
clientKey: string | null
}
@@ -369,6 +371,24 @@ function MeetingRoom({
</p>
)}
{/*
Indywidualna zgoda prowadzacego. Przyciski kamery i ekranu pojawiaja
sie pod obrazem same, bo uprawnienie zmienia serwer; ta informacja
mowi uczestnikowi, ze to nie przypadek, i ostrzega przed ekranem.
*/}
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canPublishVideo && !openVideo && (
<p className="border-b border-info-500/20 bg-info-50 px-3 py-2 text-xs text-info-600">
Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem.
</p>
)}
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canShareScreen && (
<p className="border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
Prowadzący pozwolił Ci udostępnić ekran. Wszyscy uczestnicy zobaczą to, co pokażesz,
także powiadomienia i pocztę. Przed udostępnieniem zamknij to, czego nie chcesz pokazywać,
albo udostępnij tylko jedno okno.
</p>
)}
<InteractionPanel
token={token}
onSpeakEvent={speak.apply}
@@ -377,6 +397,9 @@ function MeetingRoom({
onModeration={(event) => {
if (event.kind === 'BANNED') setBanned(true)
if (event.kind === 'UNBANNED') setBanned(false)
// uprawnienie zmienil juz serwer; nowy bilet niesie je do komunikatu
// i na wypadek ponownego wejscia po zerwaniu polaczenia
if (event.kind === 'PUBLISH_CHANGED' && join.embedded) void onRefreshTicket()
}}
onHistoryLoaded={(history) => {
setOpenMic(history.openMic)
@@ -21,6 +21,10 @@ type Participant = {
screen: boolean
chatMuted: boolean
roomBanned: boolean
/** Indywidualna zgoda prowadzacego na kamere. */
cameraAllowed: boolean
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
screenAllowed: boolean
}
type Presence = {
@@ -143,6 +147,38 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
}
}
/**
* Zgoda na kamere albo ekran dla jednej osoby.
*
* Bez okna potwierdzenia: to nie kasuje ani nie blokuje, a cofniecie jest
* jednym kliknieciem i dziala od razu, bo uprawnienie zmienia serwer.
*/
async function togglePublish(participant: Participant, what: 'camera' | 'screen') {
if (participant.registrationId === null) return
const next = what === 'camera' ? !participant.cameraAllowed : !participant.screenAllowed
setBusy(participant.identity)
try {
await client.post(
`/admin/webinars/${webinarId}/room/participants/${participant.registrationId}/publish`,
{ [what]: next },
)
toasts.success(
what === 'camera'
? next
? `${participant.name} może włączyć kamerę`
: `Wyłączono kamerę: ${participant.name}`
: next
? `${participant.name} może udostępnić ekran`
: `Wyłączono udostępnianie ekranu: ${participant.name}`,
)
await refresh()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień'))
} finally {
setBusy(null)
}
}
async function unban(registrationId: number, name: string) {
setBusy(`ban-${registrationId}`)
try {
@@ -263,6 +299,53 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
{participant.kind !== 'SPEAKER' && (
<div className="flex shrink-0 items-center gap-0.5">
{/*
Kamera i ekran tylko przy LiveKit: wylacznie tam serwer
egzekwuje osobno kazde zrodlo. Przy innych dostawcach
przycisk obiecywalby cos, czego nikt nie pilnuje.
*/}
{participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
<>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'camera')}
aria-pressed={participant.cameraAllowed}
aria-label={
participant.cameraAllowed
? `Odbierz kamerę: ${participant.name}`
: `Pozwól włączyć kamerę: ${participant.name}`
}
title={participant.cameraAllowed ? 'Odbierz kamerę' : 'Pozwól włączyć kamerę'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.cameraAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={participant.cameraAllowed ? 'video' : 'video-off'} size={16} />
</button>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'screen')}
aria-pressed={participant.screenAllowed}
aria-label={
participant.screenAllowed
? `Odbierz udostępnianie ekranu: ${participant.name}`
: `Pozwól udostępnić ekran: ${participant.name}`
}
title={participant.screenAllowed ? 'Odbierz udostępnianie ekranu' : 'Pozwól udostępnić ekran'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.screenAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name="presentation" size={16} />
</button>
</>
)}
{participant.kind === 'PARTICIPANT' && (
<button
type="button"