From 5cd13802865038cf02dd70f2dd9c48d89d630ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Fri, 4 Sep 2026 09:44:46 +0200 Subject: [PATCH] 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 --- frontend/src/pages/public/LiveKitRoom.tsx | 58 +++++++++++++++-- frontend/src/pages/public/MeetingPage.tsx | 79 ++++++++++++++++------- 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/public/LiveKitRoom.tsx b/frontend/src/pages/public/LiveKitRoom.tsx index 6ecbe6c..8b5eed5 100644 --- a/frontend/src/pages/public/LiveKitRoom.tsx +++ b/frontend/src/pages/public/LiveKitRoom.tsx @@ -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( () => ({ @@ -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" > + {/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */} + + {/* + 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. + */} + {/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */} ) } + +/** + * 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 +} diff --git a/frontend/src/pages/public/MeetingPage.tsx b/frontend/src/pages/public/MeetingPage.tsx index dec211f..516e23c 100644 --- a/frontend/src/pages/public/MeetingPage.tsx +++ b/frontend/src/pages/public/MeetingPage.tsx @@ -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 @@ -389,15 +395,44 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
}> {/* - 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. */} - + { + devicesRef.current = state + }} + />
)