Compare commits

...

3 Commits

Author SHA1 Message Date
debian a80fdd9053 Rozmycie tla wlasnej kamery
Deploy PROD / deploy (push) Successful in 4m32s
Przycisk w sali, ktory kazdy z wlasna kamera wlacza i wylacza sam,
w trakcie spotkania. Pokazuje sie wylacznie osobie z wlaczona kamera,
wiec uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy.

`@livekit/track-processors` wchodzi jako nowa zaleznosc, poza lista
z CLAUDE.md: uzgodnione. Ladowany leniwie, bo ciagnie za soba MediaPipe:
import statyczny doliczalby kilka MB do pakietu KAZDEGO wejscia na sale,
takze uczestnikom bez kamery. Sprawdzone na zbudowanym pakiecie: MediaPipe
siedzi w osobnym chunku, glowny pakiet nie urosl.

Pliki modelu ida z WLASNEJ domeny, nie z CDN: instancja w sieci zamknietej
ma miec rozmycie tak samo, a CSP nie musi dopuszczac obcych hostow.
Model (244 kB) lezy w repozytorium, a zestaw WASM kopiuje sie przy budowaniu
obrazu z `node_modules`: wazy kilkanascie MB i musi odpowiadac wersji
pakietu, wiec w repozytorium puchlby w historii i po cichu rozjezdzal sie
przy kazdej aktualizacji.

Podmiany tla na obraz swiadomie NIE ma. Segmentacja obcina wlosy i dlonie
przy slabym swietle, a kafelek wideo jest za maly, zeby cokolwiek na tle
bylo czytelne. Reklama ma wlasne miejsce w banerach, ktore sa przy tym
mierzone.

`VideoConference` zostaje nietkniety: przycisk jest obok, wiec aktualizacja
biblioteki nie wymaga odtwarzania niczego u nas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:45:07 +02:00
debian 5cd1380286 Zmiana trybu nie wycisza juz calej sali
Wylaczenie "wideo dla wszystkich" przy wlaczonym otwartym mikrofonie
wyciszalo WSZYSTKICH uczestnikow, takze mowiacych w tej chwili. Zdarzylo
sie to na zywo i zaklocilo spotkanie.

Zlozyly sie na to dwie rzeczy:

1. Stan `voiceState` byl trojstopniowy (`none` / `granted` / `video`)
   i mieszal dwa NIEZALEZNE przelaczniki prowadzacego. Wideo przykrywalo
   informacje o mikrofonie, wiec jego wylaczenie wygladalo jak zmiana
   uprawnien, chociaz prawo do mikrofonu zostawalo.

2. `key={join.token}` przemontowywal sale przy KAZDYM nowym tokenie,
   a przemontowanie jest ponownym wejsciem: provider startuje z
   `audio={false}`, wiec kazda opublikowana sciezka przepada. Token jest
   przy tym zawsze inny, bo JWT niesie czas wydania.

Sam token byl poprawny: rola SPEAKER zachowuje `canPublishAudio`.

Klucz sklada teraz `canPublishAudio` i `canPublishVideo`, czyli faktyczne
uprawnienia tego uczestnika wyliczone przez backend z roli, obu trybow
i przyznanego glosu. Zmiana trybu, ktora go nie dotyczy, nie rusza jego sali.

Do tego `initialAudio`/`initialVideo`: urzadzenia wlaczone przed
przemontowaniem wracaja wlaczone, wiec przemontowanie nieuniknione (realna
zmiana uprawnien) tez nie wycisza mowiacego. Kamera wraca wylacznie wtedy,
gdy uczestnik nadal ma do niej prawo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:44:46 +02:00
debian 2bc2e6525c Prelegent widzi zadania i banery na sali
`PublicTaskOfferController` szukal tokenu wylacznie w `registrations`,
a prelegent tam nie istnieje: nie przechodzi przez formularz zapisu i ma
wlasny token w `webinar_speakers`. Kazdy endpoint sali odpowiadal mu 404.

Objawialo sie to tym, ze prowadzacy **nie widzial wlasnych zadan**: panel
pokazywal "Nie znaleziono zapisu" przy zadaniu ogloszonym minute wczesniej
i widocznym w panelu prowadzenia. Tak samo znikaly banery i nie liczyly sie
ich wyswietlenia. Zdarzylo sie to na zywo, w trakcie spotkania.

Nowa metoda `TaskService.viewer` obsluguje oba rodzaje tokenu, wzorowana na
`PublicInteractionController`, ktory robil to juz przy historii czatu.
Przelaczone sa WYLACZNIE odczyty: lista zadan, banery i ich zdarzenia.
Zapisy (zgloszenie do zadania, wyslanie oferty) zostaja przy `participant`,
bo prelegent nie zapisuje sie na wlasne zadanie.

`registrationId` rowne null jest tu poprawna wartoscia, nie brakiem danych:
`listViews` przewidywalo to od poczatku, a `banner_events.registration_id`
jest nullowalne wlasnie dla zdarzen od osob bez zapisu.

Front: `TasksTab` dostaje tryb `readOnly`, wiec prowadzacy widzi zadania
i liczbe zapisanych, ale bez przycisku zgloszenia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:44:28 +02:00
11 changed files with 378 additions and 43 deletions
@@ -67,7 +67,9 @@ public class PublicTaskOfferController {
@GetMapping("/tasks")
@Operation(summary = "Zadania webinaru i wlasne zgloszenia")
public TasksView taskList(@PathVariable String token) {
TaskService.ParticipantDetails participant = participant(token);
// odczyt, wiec takze dla prelegenta: prowadzacy ma widziec zadania,
// ktore sam oglosil
TaskService.ParticipantDetails participant = tasks.viewer(token);
return new TasksView(tasks.listViews(participant.webinarId(), participant.registrationId()));
}
@@ -153,7 +155,7 @@ public class PublicTaskOfferController {
@GetMapping("/banners")
@Operation(summary = "Banery obowiazujace w tej chwili dla tego webinaru")
public BannersView activeBanners(@PathVariable String token) {
TaskService.ParticipantDetails participant = participant(token);
TaskService.ParticipantDetails participant = tasks.viewer(token);
return new BannersView(
banners.activeFor(participant.webinarId()),
banners.rotationSeconds());
@@ -169,7 +171,9 @@ public class PublicTaskOfferController {
@PostMapping("/banners/{bannerId}/impression")
@Operation(summary = "Zapis wyswietlenia banera, raz na uczestnika")
public void bannerImpression(@PathVariable String token, @PathVariable Long bannerId) {
TaskService.ParticipantDetails participant = participant(token);
// takze prelegent: `banner_events.registration_id` jest nullowalne
// wlasnie po to, bo zdarzenie moze pochodzic od osoby bez zapisu
TaskService.ParticipantDetails participant = tasks.viewer(token);
banners.recordImpression(bannerId, participant.registrationId());
}
@@ -177,7 +181,7 @@ public class PublicTaskOfferController {
@PostMapping("/banners/{bannerId}/click")
@Operation(summary = "Zapis klikniecia w baner")
public void bannerClick(@PathVariable String token, @PathVariable Long bannerId) {
TaskService.ParticipantDetails participant = participant(token);
TaskService.ParticipantDetails participant = tasks.viewer(token);
banners.recordClick(bannerId, participant.registrationId());
}
@@ -40,19 +40,22 @@ public class TaskService {
private final WebinarRepository webinarRepository;
private final UserRepository userRepository;
private final PlainTextSanitizer sanitizer;
private final com.softspm.webinar.repository.WebinarSpeakerRepository speakerRepository;
public TaskService(TaskRepository taskRepository,
InteractionRepository interactionRepository,
RegistrationRepository registrationRepository,
WebinarRepository webinarRepository,
UserRepository userRepository,
PlainTextSanitizer sanitizer) {
PlainTextSanitizer sanitizer,
com.softspm.webinar.repository.WebinarSpeakerRepository speakerRepository) {
this.taskRepository = taskRepository;
this.interactionRepository = interactionRepository;
this.registrationRepository = registrationRepository;
this.webinarRepository = webinarRepository;
this.userRepository = userRepository;
this.sanitizer = sanitizer;
this.speakerRepository = speakerRepository;
}
@Transactional
@@ -265,6 +268,43 @@ public class TaskService {
* Odczyt jest w serwisie, a nie w kontrolerze: @Transactional na metodzie
* wolanej z tej samej klasy nie dziala, bo samowywolanie omija proxy Springa.
*/
/**
* Osoba na sali: uczestnik ALBO prelegent.
*
* DWA RODZAJE TOKENU, JEDNO WEJSCIE
* -----------------------------------------------------------------------
* Prelegent nie jest wierszem w `registrations`: nie przechodzi przez
* formularz zapisu i ma wlasny token w `webinar_speakers`. Metoda
* `participant` szuka wylacznie zapisu, wiec kazdy endpoint sali oparty
* na niej odpowiadal prelegentowi bledem 404. Objawialo sie to tym, ze
* prowadzacy **nie widzial wlasnych zadan** ani banerow, a panel pokazywal
* "Nie znaleziono zapisu" przy poprawnie ogloszonym zadaniu.
*
* `registrationId` rowne null oznacza prelegenta i jest **poprawna
* wartoscia**, nie brakiem danych: `listViews` traktuje go jako osobe bez
* wlasnych zgloszen, bo prelegent nie zapisuje sie na wlasne zadanie.
*/
@Transactional(readOnly = true)
public ParticipantDetails viewer(String token) {
return registrationRepository.findByConfirmationToken(token)
.map(registration -> new ParticipantDetails(
registration.getId(),
registration.getWebinar().getId(),
(registration.getFirstName() + " " + registration.getLastName()).trim(),
registration.getEmail(),
registration.getPhone(),
registration.getCompany()))
.orElseGet(() -> speakerRepository.findByAccessToken(token)
.map(speaker -> new ParticipantDetails(
null,
speaker.getWebinar().getId(),
speaker.getFullName(),
speaker.getEmail(),
null,
null))
.orElseThrow(() -> new NotFoundException("Nie znaleziono zapisu")));
}
@Transactional(readOnly = true)
public ParticipantDetails participant(String confirmationToken) {
Registration registration = registrationRepository.findByConfirmationToken(confirmationToken)
+18
View File
@@ -13,6 +13,24 @@ ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# Pliki MediaPipe do rozmycia tla, kopiowane Z ZAINSTALOWANEGO PAKIETU.
#
# DLACZEGO NIE Z REPOZYTORIUM
# ---------------------------------------------------------------------------
# Zestaw WASM wazy kilkanascie MB i musi odpowiadac wersji
# `@mediapipe/tasks-vision` wciagnietej przez `@livekit/track-processors`.
# Wrzucony do repozytorium puchlby w historii na zawsze, a przy aktualizacji
# pakietu rozjezdzalby sie po cichu: rozmycie przestaje dzialac, a nic tego
# nie sygnalizuje. Kopiowanie przy budowaniu wiaze pliki z wersja pakietu.
#
# DLACZEGO NIE Z CDN
# ---------------------------------------------------------------------------
# Domyslnie biblioteka pobiera je z jsdelivr i storage.googleapis.com.
# Instancja w sieci zamknietej nie miałaby wtedy rozmycia wcale, a CSP
# musialoby dopuszczac dwa obce hosty. Serwowanie z wlasnej domeny miesci sie
# w istniejacym `connect-src 'self'`.
RUN mkdir -p /build/dist/mediapipe && cp -r node_modules/@mediapipe/tasks-vision/wasm /build/dist/mediapipe/wasm
# --- etap 2: runtime ---
FROM nginx:1.27-alpine
+36
View File
@@ -13,6 +13,7 @@
"@jitsi/react-sdk": "^1.4.4",
"@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@mui/x-charts": "^9.12.0",
"@stomp/stompjs": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
@@ -31,6 +32,7 @@
"react-router-dom": "^6.28.1"
},
"devDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
@@ -1108,6 +1110,25 @@
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@livekit/track-processors": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
"license": "Apache-2.0",
"dependencies": {
"@mediapipe/tasks-vision": "0.10.14"
},
"peerDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@mui/core-downloads-tracker": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
@@ -2464,6 +2485,21 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "*"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz",
"integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+2
View File
@@ -14,6 +14,7 @@
"@jitsi/react-sdk": "^1.4.4",
"@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@mui/x-charts": "^9.12.0",
"@stomp/stompjs": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
@@ -39,6 +40,7 @@
}
},
"devDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
Binary file not shown.
@@ -0,0 +1,132 @@
import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Icon } from '../../components/Icon'
/**
* Przelacznik rozmycia tla wlasnej kamery.
*
* DLACZEGO OSOBNY PRZYCISK, A NIE WLASNY PASEK NARZEDZI
* ---------------------------------------------------------------------------
* `VideoConference` zostaje nietkniety, zgodnie z zasada, ze nie budujemy
* wlasnego interfejsu wideo. Ten przycisk dokladamy obok, wiec aktualizacja
* biblioteki nie wymaga odtwarzania niczego u nas.
*
* DLACZEGO LADOWANIE JEST LENIWE
* ---------------------------------------------------------------------------
* `@livekit/track-processors` ciagnie za soba MediaPipe, czyli kilka MB kodu
* i WASM. Import statyczny doliczalby to do pakietu KAZDEGO wejscia na sale,
* takze uczestnikom, ktorzy kamery nie maja wcale. Modul wczytuje sie dopiero
* przy pierwszym klinieciu.
*
* DLACZEGO PLIKI IDA Z WLASNEJ DOMENY
* ---------------------------------------------------------------------------
* Domyslnie biblioteka pobiera WASM z jsdelivr, a model ze storage.googleapis.
* Instancja w sieci zamknietej nie miałaby wtedy rozmycia, a CSP musialoby
* dopuszczac dwa obce hosty. Pliki leza u nas: model w `public/mediapipe`,
* zestaw WASM kopiowany przy budowaniu obrazu z `node_modules`.
*
* DLACZEGO WYLACZAMY PRZEZ `switchTo`, A NIE `stopProcessor`
* ---------------------------------------------------------------------------
* Zatrzymanie procesora potrafi zostawic zamrozona klatke, bo sciezka jest
* przepinana w locie. Tryb `disabled` przepuszcza obraz bez zmian i nie
* przerywa publikacji.
*/
export function BackgroundBlurToggle({ className = '' }: { className?: string }) {
const { cameraTrack } = useLocalParticipant()
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
/*
* Procesor przezywa wylaczenie rozmycia, bo `switchTo` jest tansze niz
* budowanie go od nowa: MediaPipe wczytuje wtedy model ponownie i obraz
* zacina sie na chwile przy kazdym przelaczeniu.
*/
const processorRef = useRef<{ switchTo: (options: { mode: string }) => Promise<void> } | null>(null)
const track = cameraTrack?.track
const isCamera = cameraTrack?.source === Track.Source.Camera
const available = track instanceof LocalVideoTrack && isCamera && !cameraTrack?.isMuted
/*
* Wylaczona kamera zabiera sciezke, wiec procesor przestaje istniec razem
* z nia. Bez tego przycisk pokazywalby rozmycie jako wlaczone po ponownym
* wlaczeniu kamery, kiedy obraz jest juz zwykly.
*/
useEffect(() => {
if (!available) {
processorRef.current = null
setEnabled(false)
}
}, [available])
const toggle = useCallback(async () => {
if (!(track instanceof LocalVideoTrack) || busy) return
setBusy(true)
setError(null)
try {
if (!processorRef.current) {
const { BackgroundProcessor, supportsBackgroundProcessors } = await import(
'@livekit/track-processors'
)
if (!supportsBackgroundProcessors()) {
setError('Ta przeglądarka nie obsługuje rozmycia tła.')
return
}
const processor = BackgroundProcessor({
mode: 'background-blur',
blurRadius: 10,
assetPaths: {
tasksVisionFileSet: '/mediapipe/wasm',
modelAssetPath: '/mediapipe/selfie_segmenter.tflite',
},
})
await track.setProcessor(processor)
processorRef.current = processor as unknown as typeof processorRef.current
setEnabled(true)
return
}
const next = !enabled
await processorRef.current.switchTo({ mode: next ? 'background-blur' : 'disabled' })
setEnabled(next)
} catch (e) {
// Najczestsza przyczyna to slaby sprzet albo brak WebGL: mowimy o skutku,
// a nie o nazwie wyjatku, ktora uzytkownikowi nic nie mowi.
setError('Nie udało się włączyć rozmycia tła.')
console.error('Rozmycie tla:', e)
} finally {
setBusy(false)
}
}, [track, busy, enabled])
// Bez wlasnej kamery nie ma czego rozmywac: uczestnik bez prawa publikacji
// obrazu nie zobaczy tego przycisku wcale.
if (!available) return null
return (
<div className={className}>
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={`inline-flex min-h-[44px] items-center gap-2 rounded-md border px-3 text-sm transition-colors duration-fast ease-standard disabled:opacity-60 ${
enabled
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
}`}
>
<Icon name={busy ? 'loader' : 'droplet'} size={16} />
{busy ? 'Chwileczkę...' : enabled ? 'Rozmycie tła włączone' : 'Rozmyj tło'}
</button>
{error && <p className="mt-1 text-xs text-alarm-600">{error}</p>}
</div>
)
}
@@ -161,7 +161,9 @@ export function InteractionPanel({
</div>
)}
{tab === 'tasks' && <TasksTab token={token} webinarId={history.webinarId} />}
{tab === 'tasks' && (
<TasksTab token={token} webinarId={history.webinarId} readOnly={history.speaker} />
)}
{tab === 'offer' && !history.speaker && <OfferTab token={token} />}
{tab === 'chat' ? (
+54 -4
View File
@@ -3,9 +3,11 @@ import {
LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer,
VideoConference,
useLocalParticipant,
} from '@livekit/components-react'
import { RoomOptions } from 'livekit-client'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
/**
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
@@ -36,10 +38,25 @@ export function LiveKitRoom({
serverUrl,
token,
onDisconnected,
initialAudio = false,
initialVideo = false,
onDevicesChange,
}: {
serverUrl: string
token: string
onDisconnected?: () => void
/**
* Stan urzadzen po przemontowaniu sali.
*
* Przemontowanie jest ponownym wejsciem, wiec bez tego kazda zmiana
* uprawnien wycisza mowiacego: wraca on na sale z zamknietym mikrofonem
* i nie wie, ze przestal byc slyszany. Domyslnie oba wylaczone, bo przy
* PIERWSZYM wejsciu przypadkowo otwarty mikrofon slychac na calej sali.
*/
initialAudio?: boolean
initialVideo?: boolean
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
}) {
const options = useMemo<RoomOptions>(
() => ({
@@ -71,15 +88,48 @@ export function LiveKitRoom({
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
connect
video={false}
audio={false}
video={initialVideo}
audio={initialAudio}
onDisconnected={onDisconnected}
className="h-full w-full"
className="relative h-full w-full"
data-lk-theme="default"
>
<VideoConference />
{/* 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.
*/}
<BackgroundBlurToggle className="pointer-events-auto absolute left-3 top-3 z-10" />
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
<RoomAudioRenderer />
</LiveKitRoomProvider>
)
}
/**
* Zglasza stan mikrofonu i kamery do komponentu nadrzednego.
*
* Sluzy wylacznie do tego, zeby wlaczone urzadzenia przezyly przemontowanie
* sali. Przemontowanie jest ponownym wejsciem i zawsze startuje z urzadzeniami
* wylaczonymi, wiec bez zapamietania stanu kazda zmiana uprawnien na sali
* wyciszalaby osobe w trakcie wypowiedzi.
*
* Osobny komponent, bo `useLocalParticipant` dziala wylacznie wewnatrz
* kontekstu pokoju.
*/
function DeviceStateReporter({
onChange,
}: {
onChange?: (state: { audio: boolean; video: boolean }) => void
}) {
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
useEffect(() => {
onChange?.({ audio: isMicrophoneEnabled, video: isCameraEnabled })
}, [isMicrophoneEnabled, isCameraEnabled, onChange])
return null
}
+57 -22
View File
@@ -1,4 +1,4 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { publicClient } from '../../api/publicClient'
import { Icon } from '../../components/Icon'
@@ -228,21 +228,17 @@ function MeetingRoom({
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
*/
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
useEffect(() => {
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie
// miedzy nimi tez wymuszalo odswiezenie.
const next: 'none' | 'granted' | 'video' = openVideo
? 'video'
: speak.granted || openMic
? 'granted'
: 'none'
if (next === voiceState) return
setVoiceState(next)
/*
* Sygnal od prowadzacego mowi, ze uprawnienia MOGLY sie zmienic, ale nie
* mowi, jakie sa: rozstrzyga to backend, ktory zna role z zapisu, oba tryby
* i indywidualnie przyznany glos. Pobieramy wiec ticket przy kazdej zmianie,
* a o przemontowaniu sali decyduje dopiero `key` zlozony z UPRAWNIEN
* (patrz miejsce renderowania `LiveKitRoom`), nie z samego tokenu.
*/
if (join.embedded) void onRefreshTicket()
}, [speak.granted, openMic, openVideo, voiceState, join.embedded, onRefreshTicket])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speak.granted, openMic, openVideo])
const src = useMemo(() => {
if (!join.embedded) return null
@@ -379,6 +375,16 @@ function MeetingRoom({
* dziala jego gotowy komponent.
*/
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
/*
* Stan urzadzen przezywajacy przemontowanie sali.
*
* Referencja, a nie stan Reacta: przerysowanie przy kazdym wlaczeniu
* mikrofonu byloby zbedne, a wartosc jest potrzebna wylacznie w chwili
* montowania nowej sali. Referencja przezywa przemontowanie DZIECKA
* (`LiveKitRoom` ma wlasny `key`), bo siedzi w komponencie nadrzednym.
*/
const devicesRef = useRef({ audio: false, video: false })
// tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
if (!join.embedded) {
return <ExternalVideo url={join.url} />
@@ -389,15 +395,44 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
{/*
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera.
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co
robi przeladowanie, ale bez wychodzenia ze spotkania.
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
---------------------------------------------------------------
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
mikrofonu, dopoki uczestnik nie przeladuje strony.
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje
z `audio={false}`, wiec kazda opublikowana sciezka przepada
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny
ciag (JWT niesie czas wydania), takze gdy uprawnienia sa te same.
Zdarzylo sie to na zywo: prowadzacy wylaczyl "wideo dla wszystkich"
przy wlaczonym otwartym mikrofonie, front pobral token i przemontowal
sale CALEJ grupie, wyciszajac ludzi w trakcie wypowiedzi. Prawo do
mikrofonu wcale sie wtedy nie zmienilo.
Klucz sklada wiec faktyczne uprawnienia tego uczestnika, ktore
backend wylicza z roli, obu trybow i przyznanego glosu. Zmiana
trybu, ktora go nie dotyczy, nie rusza jego sali.
*/}
<LiveKitRoom key={join.token} serverUrl={join.url} token={join.token} />
<LiveKitRoom
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
serverUrl={join.url}
token={join.token}
/*
* Urzadzenia wlaczone przed przemontowaniem wracaja wlaczone.
* Kamera wraca wylacznie wtedy, gdy uczestnik nadal ma do niej
* prawo: po odebraniu "wideo dla wszystkich" serwer i tak odrzuci
* publikacje, a proba jej wznowienia dalaby blad w konsoli.
*/
initialAudio={devicesRef.current.audio && join.canPublishAudio}
initialVideo={devicesRef.current.video && join.canPublishVideo}
onDevicesChange={(state) => {
devicesRef.current = state
}}
/>
</Suspense>
</div>
)
+17 -1
View File
@@ -25,7 +25,21 @@ export type TaskView = {
createdAt: string
}
export function TasksTab({ token, webinarId }: { token: string; webinarId: number }) {
export function TasksTab({
token,
webinarId,
readOnly = false,
}: {
token: string
webinarId: number
/**
* Podglad bez mozliwosci zapisu.
*
* Prowadzacy widzi zadania, ktore sam oglosil, ale nie zapisuje sie na nie:
* nie ma wiersza w `registrations`, wiec backend i tak odrzucilby zgloszenie.
*/
readOnly?: boolean
}) {
const [tasks, setTasks] = useState<TaskView[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -129,6 +143,7 @@ export function TasksTab({ token, webinarId }: { token: string; webinarId: numbe
)}
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
{!readOnly && (
<Button
type="button"
size="sm"
@@ -139,6 +154,7 @@ export function TasksTab({ token, webinarId }: { token: string; webinarId: numbe
>
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
</Button>
)}
</div>
{task.signedUp && (