Zmiana trybu nie wycisza juz calej sali

Wylaczenie "wideo dla wszystkich" przy wlaczonym otwartym mikrofonie
wyciszalo WSZYSTKICH uczestnikow, takze mowiacych w tej chwili. Zdarzylo
sie to na zywo i zaklocilo spotkanie.

Zlozyly sie na to dwie rzeczy:

1. Stan `voiceState` byl trojstopniowy (`none` / `granted` / `video`)
   i mieszal dwa NIEZALEZNE przelaczniki prowadzacego. Wideo przykrywalo
   informacje o mikrofonie, wiec jego wylaczenie wygladalo jak zmiana
   uprawnien, chociaz prawo do mikrofonu zostawalo.

2. `key={join.token}` przemontowywal sale przy KAZDYM nowym tokenie,
   a przemontowanie jest ponownym wejsciem: provider startuje z
   `audio={false}`, wiec kazda opublikowana sciezka przepada. Token jest
   przy tym zawsze inny, bo JWT niesie czas wydania.

Sam token byl poprawny: rola SPEAKER zachowuje `canPublishAudio`.

Klucz sklada teraz `canPublishAudio` i `canPublishVideo`, czyli faktyczne
uprawnienia tego uczestnika wyliczone przez backend z roli, obu trybow
i przyznanego glosu. Zmiana trybu, ktora go nie dotyczy, nie rusza jego sali.

Do tego `initialAudio`/`initialVideo`: urzadzenia wlaczone przed
przemontowaniem wracaja wlaczone, wiec przemontowanie nieuniknione (realna
zmiana uprawnien) tez nie wycisza mowiacego. Kamera wraca wylacznie wtedy,
gdy uczestnik nadal ma do niej prawo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 09:44:46 +02:00
parent 2bc2e6525c
commit 5cd1380286
2 changed files with 111 additions and 26 deletions
+54 -4
View File
@@ -3,9 +3,11 @@ import {
LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer,
VideoConference,
useLocalParticipant,
} from '@livekit/components-react'
import { RoomOptions } from 'livekit-client'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
/**
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
@@ -36,10 +38,25 @@ export function LiveKitRoom({
serverUrl,
token,
onDisconnected,
initialAudio = false,
initialVideo = false,
onDevicesChange,
}: {
serverUrl: string
token: string
onDisconnected?: () => void
/**
* Stan urzadzen po przemontowaniu sali.
*
* Przemontowanie jest ponownym wejsciem, wiec bez tego kazda zmiana
* uprawnien wycisza mowiacego: wraca on na sale z zamknietym mikrofonem
* i nie wie, ze przestal byc slyszany. Domyslnie oba wylaczone, bo przy
* PIERWSZYM wejsciu przypadkowo otwarty mikrofon slychac na calej sali.
*/
initialAudio?: boolean
initialVideo?: boolean
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
}) {
const options = useMemo<RoomOptions>(
() => ({
@@ -71,15 +88,48 @@ export function LiveKitRoom({
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
connect
video={false}
audio={false}
video={initialVideo}
audio={initialAudio}
onDisconnected={onDisconnected}
className="h-full w-full"
className="relative h-full w-full"
data-lk-theme="default"
>
<VideoConference />
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
<DeviceStateReporter onChange={onDevicesChange} />
{/*
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
kafelkami, bo `VideoConference` zajmuje caly kontener.
*/}
<BackgroundBlurToggle className="pointer-events-auto absolute left-3 top-3 z-10" />
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
<RoomAudioRenderer />
</LiveKitRoomProvider>
)
}
/**
* Zglasza stan mikrofonu i kamery do komponentu nadrzednego.
*
* Sluzy wylacznie do tego, zeby wlaczone urzadzenia przezyly przemontowanie
* sali. Przemontowanie jest ponownym wejsciem i zawsze startuje z urzadzeniami
* wylaczonymi, wiec bez zapamietania stanu kazda zmiana uprawnien na sali
* wyciszalaby osobe w trakcie wypowiedzi.
*
* Osobny komponent, bo `useLocalParticipant` dziala wylacznie wewnatrz
* kontekstu pokoju.
*/
function DeviceStateReporter({
onChange,
}: {
onChange?: (state: { audio: boolean; video: boolean }) => void
}) {
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
useEffect(() => {
onChange?.({ audio: isMicrophoneEnabled, video: isCameraEnabled })
}, [isMicrophoneEnabled, isCameraEnabled, onChange])
return null
}
+57 -22
View File
@@ -1,4 +1,4 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { publicClient } from '../../api/publicClient'
import { Icon } from '../../components/Icon'
@@ -228,21 +228,17 @@ function MeetingRoom({
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
*/
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
useEffect(() => {
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie
// miedzy nimi tez wymuszalo odswiezenie.
const next: 'none' | 'granted' | 'video' = openVideo
? 'video'
: speak.granted || openMic
? 'granted'
: 'none'
if (next === voiceState) return
setVoiceState(next)
/*
* Sygnal od prowadzacego mowi, ze uprawnienia MOGLY sie zmienic, ale nie
* mowi, jakie sa: rozstrzyga to backend, ktory zna role z zapisu, oba tryby
* i indywidualnie przyznany glos. Pobieramy wiec ticket przy kazdej zmianie,
* a o przemontowaniu sali decyduje dopiero `key` zlozony z UPRAWNIEN
* (patrz miejsce renderowania `LiveKitRoom`), nie z samego tokenu.
*/
if (join.embedded) void onRefreshTicket()
}, [speak.granted, openMic, openVideo, voiceState, join.embedded, onRefreshTicket])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speak.granted, openMic, openVideo])
const src = useMemo(() => {
if (!join.embedded) return null
@@ -379,6 +375,16 @@ function MeetingRoom({
* dziala jego gotowy komponent.
*/
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
/*
* Stan urzadzen przezywajacy przemontowanie sali.
*
* Referencja, a nie stan Reacta: przerysowanie przy kazdym wlaczeniu
* mikrofonu byloby zbedne, a wartosc jest potrzebna wylacznie w chwili
* montowania nowej sali. Referencja przezywa przemontowanie DZIECKA
* (`LiveKitRoom` ma wlasny `key`), bo siedzi w komponencie nadrzednym.
*/
const devicesRef = useRef({ audio: false, video: false })
// tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
if (!join.embedded) {
return <ExternalVideo url={join.url} />
@@ -389,15 +395,44 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
{/*
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera.
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co
robi przeladowanie, ale bez wychodzenia ze spotkania.
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH 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.
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.
*/}
<LiveKitRoom key={join.token} serverUrl={join.url} token={join.token} />
<LiveKitRoom
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
serverUrl={join.url}
token={join.token}
/*
* Urzadzenia wlaczone przed przemontowaniem wracaja wlaczone.
* Kamera wraca wylacznie wtedy, gdy uczestnik nadal ma do niej
* prawo: po odebraniu "wideo dla wszystkich" serwer i tak odrzuci
* publikacje, a proba jej wznowienia dalaby blad w konsoli.
*/
initialAudio={devicesRef.current.audio && join.canPublishAudio}
initialVideo={devicesRef.current.video && join.canPublishVideo}
onDevicesChange={(state) => {
devicesRef.current = state
}}
/>
</Suspense>
</div>
)