Scena webinaru: kafelki tylko dla prowadzacych i mowiacych
Deploy PROD / deploy (push) Successful in 2m21s

`VideoConference` z LiveKit daje kafelek (z zaslepka sylwetki) KAZDEMU
uczestnikowi pokoju, niezaleznie od tego, czy nadaje obraz. To uklad
wideokonferencji, nie webinaru. Test z botami na produkcji 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 rol istnialo dotad wylacznie w uprawnieniach tokenu: uczestnik
bez "wideo dla wszystkich" nie wlaczy kamery, i to dziala. Warstwa widoku tego
nie odzwierciedlala.

`WebinarStage` odwzorowuje `VideoConference` 2.9 z tych samych komponentow
(siatka, fokus na udostepnionym ekranie, pasek sterowania) i zmienia WYLACZNIE
filtr sciezek. Kafelek dostaje:

- prelegent (`spk-...`) zawsze, takze z wylaczona kamera,
- kazdy, kto nadaje kamere albo ekran, wiec uczestnicy przy wlaczonym "wideo
  dla wszystkich" pojawiaja sie sami, bez osobnej logiki trybu,
- kazdy z otwartym mikrofonem, czyli uczestnik z przyznanym glosem.

Pozostali widzowie nie maja kafelkow; zastepuje je licznik "Na sali: N osob".
Filtr reaguje na publikacje i wyciszenie sciezek, a nie tylko na zmiane
aktywnego mowcy: uczestnik, ktory wlaczyl mikrofon, ma pojawic sie od razu.

Z paska sterowania zniknal przycisk czatu LiveKit. Byl drugim czatem obok
naszego, niemoderowanym i niezapisywanym, wiec mylil uczestnikow co do tego,
gdzie pisac.

Scena nie daje zadnych uprawnien: sam wyglad nie zmienia tego, kto moze
nadawac. To nadal egzekwuje token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 07:22:22 +02:00
parent 305b1e6144
commit 698425bbe3
2 changed files with 205 additions and 5 deletions
+11 -5
View File
@@ -2,21 +2,23 @@ import '@livekit/components-styles'
import { import {
LiveKitRoom as LiveKitRoomProvider, LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer, RoomAudioRenderer,
VideoConference,
useLocalParticipant, useLocalParticipant,
} from '@livekit/components-react' } from '@livekit/components-react'
import { RoomOptions } from 'livekit-client' import { RoomOptions } from 'livekit-client'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { BackgroundBlurToggle } from './BackgroundBlurToggle' import { BackgroundBlurToggle } from './BackgroundBlurToggle'
import { WebinarStage } from './WebinarStage'
/** /**
* Pokoj LiveKit osadzony w naszym ekranie spotkania. * 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 * wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby
* miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi. * 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, * **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania,
* zapytania ofertowe, banery i pasek akcji. * zapytania ofertowe, banery i pasek akcji.
* *
@@ -94,13 +96,17 @@ export function LiveKitRoom({
className="relative h-full w-full" className="relative h-full w-full"
data-lk-theme="default" data-lk-theme="default"
> >
<VideoConference /> {/*
Scena webinaru zamiast `VideoConference`: kafelki tylko dla prowadzacych
i osob, ktore nadaja obraz albo dzwiek. Uzasadnienie w `WebinarStage`.
*/}
<WebinarStage />
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */} {/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
<DeviceStateReporter onChange={onDevicesChange} /> <DeviceStateReporter onChange={onDevicesChange} />
{/* {/*
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
kafelkami, bo `VideoConference` zajmuje caly kontener. kafelkami, bo scena zajmuje caly kontener.
*/} */}
<BackgroundBlurToggle className="pointer-events-auto absolute left-3 top-3 z-10" /> <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 */}
+194
View File
@@ -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<string | null>(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 (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext}>
<div className="lk-video-conference-inner">
<AudienceCount />
{visible.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-sm text-white/70">
Prowadzący jeszcze nie dołączył. Spotkanie zacznie się, gdy wejdzie na salę.
</div>
) : !focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={visible}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
{/*
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.
*/}
<ControlBar controls={{ chat: false, settings: false }} />
</div>
</LayoutContextProvider>
<ConnectionStateToast />
</div>
)
}
/** 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 (
<div className="pointer-events-none absolute right-3 top-3 z-10 rounded-md bg-black/50 px-2.5 py-1 text-xs text-white">
Na sali: <span className="hx-data font-semibold">{count}</span> {personLabel(count)}
</div>
)
}
/** 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'
}