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 {
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"
>
<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 */}
<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.
kafelkami, bo scena 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 */}
+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'
}