Scena webinaru: kafelki tylko dla prowadzacych i mowiacych
Deploy PROD / deploy (push) Successful in 2m21s
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:
@@ -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 */}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user