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 {
|
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 */}
|
||||||
|
|||||||
@@ -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