Przyznanie i odebranie glosu oraz oba tryby (otwarty mikrofon, wideo dla wszystkich) zmieniaja uprawnienia uczestnika przez updateParticipant, bez ponownego wejscia na sale. Odebranie najpierw wycisza sciezke na serwerze, wiec dziala takze u klienta, ktory nie odebral zdarzenia. Sala LiveKit ma staly key; mikrofon wlacza sie sam po przyznaniu glosu. Rola uczestnika liczona w jednym miejscu (VideoService.participantRole) dla biletu i zmiany na zywo. RoomPermissionTest pilnuje jawnej listy zrodel publikacji. Blokada pisania na czacie ma ikone przekreslonego dymku i znacznik "bez czatu" zamiast przekreslonego mikrofonu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
MailCheck,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MessageSquareOff,
|
||||
Pencil,
|
||||
Plus,
|
||||
Play,
|
||||
@@ -106,6 +107,7 @@ const ICONS = {
|
||||
'mail-check': MailCheck,
|
||||
menu: Menu,
|
||||
'message-square': MessageSquare,
|
||||
'message-square-off': MessageSquareOff,
|
||||
pencil: Pencil,
|
||||
plus: Plus,
|
||||
play: Play,
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
LiveKitRoom as LiveKitRoomProvider,
|
||||
RoomAudioRenderer,
|
||||
useLocalParticipant,
|
||||
useLocalParticipantPermissions,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomOptions } from 'livekit-client'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
|
||||
import { WebinarStage } from './WebinarStage'
|
||||
|
||||
@@ -43,6 +44,7 @@ export function LiveKitRoom({
|
||||
initialAudio = false,
|
||||
initialVideo = false,
|
||||
onDevicesChange,
|
||||
speakGranted = false,
|
||||
}: {
|
||||
serverUrl: string
|
||||
token: string
|
||||
@@ -59,6 +61,12 @@ export function LiveKitRoom({
|
||||
initialVideo?: boolean
|
||||
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
|
||||
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
|
||||
/**
|
||||
* Prowadzacy przyznal glos na prosbe uczestnika. Mikrofon wlacza sie wtedy
|
||||
* sam, gdy serwer nada prawo publikacji. Otwarty mikrofon dla calej sali tego
|
||||
* nie robi: tam nikt o glos nie prosil.
|
||||
*/
|
||||
speakGranted?: boolean
|
||||
}) {
|
||||
const options = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
@@ -103,6 +111,7 @@ export function LiveKitRoom({
|
||||
<WebinarStage />
|
||||
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
|
||||
<DeviceStateReporter onChange={onDevicesChange} />
|
||||
<MicOnGrant granted={speakGranted} />
|
||||
{/*
|
||||
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
|
||||
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
|
||||
@@ -139,3 +148,34 @@ function DeviceStateReporter({
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Wlacza mikrofon po przyznaniu glosu.
|
||||
*
|
||||
* Uczestnik prosil o glos, wiec po zgodzie ma od razu mowic, a nie szukac
|
||||
* przycisku. Czeka na UPRAWNIENIE od serwera, a nie na samo zdarzenie
|
||||
* o przyznaniu: to drugie potrafi przyjsc szybciej, a proba publikacji przed
|
||||
* nadaniem prawa konczy sie odmowa.
|
||||
*
|
||||
* Raz na przyznanie: jesli uczestnik sam sie wyciszy, nie wlaczamy mu
|
||||
* mikrofonu z powrotem przy kolejnej zmianie uprawnien.
|
||||
*/
|
||||
function MicOnGrant({ granted }: { granted: boolean }) {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const permissions = useLocalParticipantPermissions()
|
||||
const done = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!granted) {
|
||||
done.current = false
|
||||
return
|
||||
}
|
||||
if (done.current || !permissions?.canPublish) return
|
||||
done.current = true
|
||||
localParticipant.setMicrophoneEnabled(true).catch(() => {
|
||||
// brak zgody przegladarki na mikrofon: uczestnik wlaczy go przyciskiem
|
||||
})
|
||||
}, [granted, permissions, localParticipant])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -217,6 +217,8 @@ function SpeakButton({
|
||||
/*
|
||||
* Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia:
|
||||
* mowi o tym, co uczestnik faktycznie moze, a nie o tym, o co prosil.
|
||||
* Nie twierdzi, ze mikrofon jest wlaczony: sala wlacza go sama po
|
||||
* przyznaniu glosu, ale uczestnik moze go zaraz wyciszyc przyciskiem.
|
||||
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
|
||||
*/
|
||||
if (speak.granted) {
|
||||
@@ -224,7 +226,7 @@ function SpeakButton({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
|
||||
<Icon name="check-circle" size={16} />
|
||||
{canPublishAudio ? 'Masz głos, mikrofon jest włączony' : 'Masz głos, trwa włączanie mikrofonu...'}
|
||||
{canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -303,7 +303,7 @@ function MeetingRoom({
|
||||
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
|
||||
i banery zostaja nasze niezaleznie od tego wyboru.
|
||||
*/}
|
||||
<VideoArea join={join} src={src} />
|
||||
<VideoArea join={join} src={src} speakGranted={speak.granted} />
|
||||
|
||||
{/*
|
||||
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
|
||||
@@ -396,7 +396,15 @@ function MeetingRoom({
|
||||
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
|
||||
* dziala jego gotowy komponent.
|
||||
*/
|
||||
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
function VideoArea({
|
||||
join,
|
||||
src,
|
||||
speakGranted,
|
||||
}: {
|
||||
join: JoinView
|
||||
src: string | null
|
||||
speakGranted: boolean
|
||||
}) {
|
||||
/*
|
||||
* Stan urzadzen przezywajacy przemontowanie sali.
|
||||
*
|
||||
@@ -417,30 +425,21 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||
<Suspense fallback={<VideoLoading />}>
|
||||
{/*
|
||||
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
|
||||
BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
|
||||
---------------------------------------------------------------
|
||||
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
|
||||
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
|
||||
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
|
||||
mikrofonu, dopoki uczestnik nie przeladuje strony.
|
||||
Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
|
||||
po stronie backendu), a biblioteka sama pokazuje albo chowa
|
||||
przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
|
||||
przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
|
||||
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
|
||||
|
||||
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje
|
||||
z `audio={false}`, wiec kazda opublikowana sciezka przepada
|
||||
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od
|
||||
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny
|
||||
ciag (JWT niesie czas wydania), takze gdy uprawnienia sa te same.
|
||||
|
||||
Zdarzylo sie to na zywo: prowadzacy wylaczyl "wideo dla wszystkich"
|
||||
przy wlaczonym otwartym mikrofonie, front pobral token i przemontowal
|
||||
sale CALEJ grupie, wyciszajac ludzi w trakcie wypowiedzi. Prawo do
|
||||
mikrofonu wcale sie wtedy nie zmienilo.
|
||||
|
||||
Klucz sklada wiec faktyczne uprawnienia tego uczestnika, ktore
|
||||
backend wylicza z roli, obu trybow i przyznanego glosu. Zmiana
|
||||
trybu, ktora go nie dotyczy, nie rusza jego sali.
|
||||
Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
|
||||
sali podmiana nic nie robi (`room.connect` przy stanie Connected
|
||||
konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
|
||||
po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
|
||||
*/}
|
||||
<LiveKitRoom
|
||||
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
|
||||
key="sala"
|
||||
serverUrl={join.url}
|
||||
token={join.token}
|
||||
/*
|
||||
@@ -454,6 +453,7 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
onDevicesChange={(state) => {
|
||||
devicesRef.current = state
|
||||
}}
|
||||
speakGranted={speakGranted}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -341,7 +341,7 @@ export function HostConsolePage() {
|
||||
}
|
||||
toasts.success(
|
||||
kind === 'mute'
|
||||
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
|
||||
? value ? 'Zablokowano pisanie na czacie' : 'Przywrócono pisanie na czacie'
|
||||
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -499,7 +499,7 @@ function ChatColumn({
|
||||
</span>
|
||||
{message.authorChatMuted && (
|
||||
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
|
||||
wyciszony
|
||||
bez czatu
|
||||
</span>
|
||||
)}
|
||||
{message.authorRoomBanned && (
|
||||
@@ -518,11 +518,11 @@ function ChatColumn({
|
||||
onClick={() =>
|
||||
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
|
||||
}
|
||||
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
|
||||
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
|
||||
aria-label={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
|
||||
title={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
|
||||
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
|
||||
>
|
||||
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
|
||||
<Icon name={message.authorChatMuted ? 'message-square' : 'message-square-off'} size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -134,7 +134,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
||||
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
|
||||
{ value: !participant.chatMuted },
|
||||
)
|
||||
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Wyciszono na czacie')
|
||||
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Zablokowano pisanie na czacie')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
|
||||
@@ -229,7 +229,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
||||
<span className="truncate font-medium text-slate-800">{participant.name}</span>
|
||||
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
|
||||
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
|
||||
{participant.chatMuted && <Badge tone="warn">wyciszony</Badge>}
|
||||
{participant.chatMuted && <Badge tone="warn">bez czatu</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||
{participant.email && <span className="truncate">{participant.email}</span>}
|
||||
@@ -271,12 +271,12 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
||||
aria-label={
|
||||
participant.chatMuted
|
||||
? `Przywróć pisanie na czacie: ${participant.name}`
|
||||
: `Wycisz na czacie: ${participant.name}`
|
||||
: `Zablokuj pisanie na czacie: ${participant.name}`
|
||||
}
|
||||
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Wycisz na czacie'}
|
||||
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40"
|
||||
>
|
||||
<Icon name={participant.chatMuted ? 'message-square' : 'mic-off'} size={16} />
|
||||
<Icon name={participant.chatMuted ? 'message-square' : 'message-square-off'} size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user