diff --git a/frontend/src/pages/public/LiveKitRoom.tsx b/frontend/src/pages/public/LiveKitRoom.tsx index 8b5eed5..4cd534e 100644 --- a/frontend/src/pages/public/LiveKitRoom.tsx +++ b/frontend/src/pages/public/LiveKitRoom.tsx @@ -2,21 +2,23 @@ import '@livekit/components-styles' import { LiveKitRoom as LiveKitRoomProvider, RoomAudioRenderer, - VideoConference, useLocalParticipant, } from '@livekit/components-react' import { RoomOptions } from 'livekit-client' import { useEffect, useMemo } from 'react' import { BackgroundBlurToggle } from './BackgroundBlurToggle' +import { WebinarStage } from './WebinarStage' /** * Pokoj LiveKit osadzony w naszym ekranie spotkania. * - * GOTOWY KOMPONENT, NIE WLASNY INTERFEJS + * GOTOWE KOMPONENTY, NIE WLASNY INTERFEJS * ------------------------------------------------------------------------ - * `VideoConference` daje uklad kafelkow, przelaczanie na aktywnego mowce, + * Komponenty LiveKit daja uklad kafelkow, przelaczanie na aktywnego mowce, * wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby * miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi. + * `WebinarStage` sklada je tak samo jak `VideoConference`, zmieniajac + * wylacznie to, kto dostaje kafelek. * **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania, * zapytania ofertowe, banery i pasek akcji. * @@ -94,13 +96,17 @@ export function LiveKitRoom({ className="relative h-full w-full" data-lk-theme="default" > - + {/* + Scena webinaru zamiast `VideoConference`: kafelki tylko dla prowadzacych + i osob, ktore nadaja obraz albo dzwiek. Uzasadnienie w `WebinarStage`. + */} + {/* 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. + kafelkami, bo scena zajmuje caly kontener. */} {/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */} diff --git a/frontend/src/pages/public/WebinarStage.tsx b/frontend/src/pages/public/WebinarStage.tsx new file mode 100644 index 0000000..0e22e8c --- /dev/null +++ b/frontend/src/pages/public/WebinarStage.tsx @@ -0,0 +1,194 @@ +import { + CarouselLayout, + ConnectionStateToast, + ControlBar, + FocusLayout, + FocusLayoutContainer, + GridLayout, + LayoutContextProvider, + ParticipantTile, + isTrackReference, + useCreateLayoutContext, + useParticipants, + usePinnedTracks, + useTracks, + type TrackReferenceOrPlaceholder, +} from '@livekit/components-react' +import { RoomEvent, Track } from 'livekit-client' +import { useEffect, useMemo, useRef } from 'react' + +/** + * Scena webinaru: kafelki wylacznie dla osob, ktore cos wnosza do obrazu. + * + * DLACZEGO NIE `VideoConference` + * ------------------------------------------------------------------------ + * Gotowy komponent LiveKit daje kafelek (z zaslepka sylwetki) KAZDEMU + * uczestnikowi pokoju, niezaleznie od tego, czy nadaje obraz. To uklad + * wideokonferencji, nie webinaru. Test ze stu botami pokazal skutek: prelegent + * gubil sie wsrod pustych sylwetek rozlozonych na kilka stron, a widz musial + * przewijac, zeby znalezc osobe, ktora mowi. Przy czterech osobach nikt tego + * nie zauwazyl. + * + * Rozroznienie ról istnialo dotad WYLACZNIE w uprawnieniach tokenu: uczestnik + * bez "wideo dla wszystkich" nie wlaczy kamery, i to dziala. Warstwa widoku + * tego nie odzwierciedlala. + * + * TO NADAL SA KOMPONENTY LIVEKIT + * ------------------------------------------------------------------------ + * Uklad jest odwzorowaniem `VideoConference` z wersji 2.9 (siatka, fokus na + * udostepnionym ekranie, pasek sterowania), zbudowanym z tych samych + * komponentow. Zmienia sie wylacznie filtr sciezek. Nie budujemy wlasnego + * interfejsu wideo, zgodnie z zasada projektu. + * + * KTO DOSTAJE KAFELEK + * ------------------------------------------------------------------------ + * - prelegent (`spk-...`) zawsze, takze z wylaczona kamera: widz ma wiedziec, + * kto prowadzi, zanim prowadzacy wlaczy obraz, + * - kazdy, kto nadaje kamere albo ekran: uczestnicy przy wlaczonym "wideo dla + * wszystkich" pojawiaja sie sami, bez osobnej logiki trybu, + * - kazdy z otwartym mikrofonem: uczestnik z przyznanym glosem ma byc + * widoczny wtedy, gdy mowi. + * Pozostali widzowie nie maja kafelkow; ich liczba jest na pasku nad scena. + * + * Tozsamosci `spk-` i `reg-` nadaje backend (`VideoService`), wiec rozpoznanie + * prelegenta nie zalezy od niczego, co przegladarka mogla by podmienic w sposob + * majacy znaczenie: sam wyglad sceny nie daje zadnych uprawnien. + */ +export function WebinarStage() { + const tracks = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { + /* + * Oprocz zmiany mowcy filtr zalezy od wyciszenia i publikacji sciezek: + * uczestnik, ktory dostal glos i wlaczyl mikrofon, ma pojawic sie od razu, + * a nie przy najblizszej zmianie aktywnego mowcy. + */ + updateOnlyOn: [ + RoomEvent.ActiveSpeakersChanged, + RoomEvent.TrackPublished, + RoomEvent.TrackUnpublished, + RoomEvent.TrackMuted, + RoomEvent.TrackUnmuted, + RoomEvent.LocalTrackPublished, + RoomEvent.LocalTrackUnpublished, + RoomEvent.ParticipantConnected, + RoomEvent.ParticipantDisconnected, + ], + onlySubscribed: false, + }, + ) + + const visible = useMemo(() => tracks.filter(isOnStage), [tracks]) + + const layoutContext = useCreateLayoutContext() + const focusTrack = usePinnedTracks(layoutContext)?.[0] + const carouselTracks = visible.filter((track) => !sameTrack(track, focusTrack)) + + /* + * Udostepniony ekran trafia na duze okno sam, tak samo jak w `VideoConference`: + * przy webinarze to zwykle prezentacja, na ktora wszyscy maja patrzec. + */ + const screenShares = visible + .filter(isTrackReference) + .filter((track) => track.publication.source === Track.Source.ScreenShare) + const autoFocused = useRef(null) + + useEffect(() => { + const first = screenShares.find((track) => track.publication.isSubscribed) + if (first && autoFocused.current === null) { + layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: first }) + autoFocused.current = first.publication.trackSid + } else if ( + autoFocused.current && + !screenShares.some((track) => track.publication.trackSid === autoFocused.current) + ) { + layoutContext.pin.dispatch?.({ msg: 'clear_pin' }) + autoFocused.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screenShares.map((t) => `${t.publication.trackSid}_${t.publication.isSubscribed}`).join()]) + + return ( +
+ +
+ + + {visible.length === 0 ? ( +
+ Prowadzący jeszcze nie dołączył. Spotkanie zacznie się, gdy wejdzie na salę. +
+ ) : !focusTrack ? ( +
+ + + +
+ ) : ( +
+ + + + + {focusTrack && } + +
+ )} + + {/* + Bez przycisku czatu LiveKit: czat, pytania i moderacja sa w naszym + panelu obok, a drugi czat w pasku wideo bylby niemoderowany + i niezapisywany, czyli mylil uczestnikow co do tego, gdzie pisac. + */} + +
+
+ +
+ ) +} + +/** Czy sciezka ma dostac kafelek. Regula opisana w komentarzu komponentu. */ +function isOnStage(track: TrackReferenceOrPlaceholder): boolean { + if (track.source === Track.Source.ScreenShare) return true + + const participant = track.participant + if (participant.identity.startsWith('spk-')) return true + if (isTrackReference(track) && !track.publication.isMuted) return true + if (participant.isMicrophoneEnabled) return true + + return false +} + +function sameTrack(a: TrackReferenceOrPlaceholder, b: TrackReferenceOrPlaceholder | undefined) { + if (!b) return false + return a.participant.identity === b.participant.identity && a.source === b.source +} + +/** + * Liczba osob na sali. + * + * Zastepuje sylwetki widzow: informacja "jest nas dwiescie" jest wazna, ale + * dwiescie pustych kafelkow jej nie przekazuje, tylko zaslania prowadzacego. + */ +function AudienceCount() { + const count = useParticipants().length + + return ( +
+ Na sali: {count} {personLabel(count)} +
+ ) +} + +/** Polska odmiana: 1 osoba, 2-4 osoby, 5-21 osob, 22-24 osoby. */ +function personLabel(n: number): string { + if (n === 1) return 'osoba' + const last = n % 10 + const lastTwo = n % 100 + if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return 'osoby' + return 'osób' +}