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, LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer, RoomAudioRenderer,
VideoConference, VideoConference,
useLocalParticipant,
} from '@livekit/components-react' } from '@livekit/components-react'
import { RoomOptions } from 'livekit-client' 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. * Pokoj LiveKit osadzony w naszym ekranie spotkania.
@@ -36,10 +38,25 @@ export function LiveKitRoom({
serverUrl, serverUrl,
token, token,
onDisconnected, onDisconnected,
initialAudio = false,
initialVideo = false,
onDevicesChange,
}: { }: {
serverUrl: string serverUrl: string
token: string token: string
onDisconnected?: () => void 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>( const options = useMemo<RoomOptions>(
() => ({ () => ({
@@ -71,15 +88,48 @@ export function LiveKitRoom({
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy // wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali // zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
connect connect
video={false} video={initialVideo}
audio={false} audio={initialAudio}
onDisconnected={onDisconnected} onDisconnected={onDisconnected}
className="h-full w-full" className="relative h-full w-full"
data-lk-theme="default" data-lk-theme="default"
> >
<VideoConference /> <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 */} {/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
<RoomAudioRenderer /> <RoomAudioRenderer />
</LiveKitRoomProvider> </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 { useParams } from 'react-router-dom'
import { publicClient } from '../../api/publicClient' import { publicClient } from '../../api/publicClient'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
@@ -228,21 +228,17 @@ function MeetingRoom({
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie, * wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie. * a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
*/ */
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
useEffect(() => { useEffect(() => {
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie /*
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo * Sygnal od prowadzacego mowi, ze uprawnienia MOGLY sie zmienic, ale nie
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie * mowi, jakie sa: rozstrzyga to backend, ktory zna role z zapisu, oba tryby
// miedzy nimi tez wymuszalo odswiezenie. * i indywidualnie przyznany glos. Pobieramy wiec ticket przy kazdej zmianie,
const next: 'none' | 'granted' | 'video' = openVideo * a o przemontowaniu sali decyduje dopiero `key` zlozony z UPRAWNIEN
? 'video' * (patrz miejsce renderowania `LiveKitRoom`), nie z samego tokenu.
: speak.granted || openMic */
? 'granted'
: 'none'
if (next === voiceState) return
setVoiceState(next)
if (join.embedded) void onRefreshTicket() 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(() => { const src = useMemo(() => {
if (!join.embedded) return null if (!join.embedded) return null
@@ -379,6 +375,16 @@ function MeetingRoom({
* dziala jego gotowy komponent. * dziala jego gotowy komponent.
*/ */
function VideoArea({ join, src }: { join: JoinView; src: string | null }) { 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 // tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
if (!join.embedded) { if (!join.embedded) {
return <ExternalVideo url={join.url} /> 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"> <div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}> <Suspense fallback={<VideoLoading />}>
{/* {/*
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera. ---------------------------------------------------------------
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie `LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana trzeba go przemontowac: sama podmiana propa `token` nie otworzy
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co mikrofonu, dopoki uczestnik nie przeladuje strony.
robi przeladowanie, ale bez wychodzenia ze spotkania.
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> </Suspense>
</div> </div>
) )