Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a80fdd9053 | |||
| 5cd1380286 | |||
| 2bc2e6525c |
+8
-4
@@ -67,7 +67,9 @@ public class PublicTaskOfferController {
|
|||||||
@GetMapping("/tasks")
|
@GetMapping("/tasks")
|
||||||
@Operation(summary = "Zadania webinaru i wlasne zgloszenia")
|
@Operation(summary = "Zadania webinaru i wlasne zgloszenia")
|
||||||
public TasksView taskList(@PathVariable String token) {
|
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()));
|
return new TasksView(tasks.listViews(participant.webinarId(), participant.registrationId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ public class PublicTaskOfferController {
|
|||||||
@GetMapping("/banners")
|
@GetMapping("/banners")
|
||||||
@Operation(summary = "Banery obowiazujace w tej chwili dla tego webinaru")
|
@Operation(summary = "Banery obowiazujace w tej chwili dla tego webinaru")
|
||||||
public BannersView activeBanners(@PathVariable String token) {
|
public BannersView activeBanners(@PathVariable String token) {
|
||||||
TaskService.ParticipantDetails participant = participant(token);
|
TaskService.ParticipantDetails participant = tasks.viewer(token);
|
||||||
return new BannersView(
|
return new BannersView(
|
||||||
banners.activeFor(participant.webinarId()),
|
banners.activeFor(participant.webinarId()),
|
||||||
banners.rotationSeconds());
|
banners.rotationSeconds());
|
||||||
@@ -169,7 +171,9 @@ public class PublicTaskOfferController {
|
|||||||
@PostMapping("/banners/{bannerId}/impression")
|
@PostMapping("/banners/{bannerId}/impression")
|
||||||
@Operation(summary = "Zapis wyswietlenia banera, raz na uczestnika")
|
@Operation(summary = "Zapis wyswietlenia banera, raz na uczestnika")
|
||||||
public void bannerImpression(@PathVariable String token, @PathVariable Long bannerId) {
|
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());
|
banners.recordImpression(bannerId, participant.registrationId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +181,7 @@ public class PublicTaskOfferController {
|
|||||||
@PostMapping("/banners/{bannerId}/click")
|
@PostMapping("/banners/{bannerId}/click")
|
||||||
@Operation(summary = "Zapis klikniecia w baner")
|
@Operation(summary = "Zapis klikniecia w baner")
|
||||||
public void bannerClick(@PathVariable String token, @PathVariable Long bannerId) {
|
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());
|
banners.recordClick(bannerId, participant.registrationId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,19 +40,22 @@ public class TaskService {
|
|||||||
private final WebinarRepository webinarRepository;
|
private final WebinarRepository webinarRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final PlainTextSanitizer sanitizer;
|
private final PlainTextSanitizer sanitizer;
|
||||||
|
private final com.softspm.webinar.repository.WebinarSpeakerRepository speakerRepository;
|
||||||
|
|
||||||
public TaskService(TaskRepository taskRepository,
|
public TaskService(TaskRepository taskRepository,
|
||||||
InteractionRepository interactionRepository,
|
InteractionRepository interactionRepository,
|
||||||
RegistrationRepository registrationRepository,
|
RegistrationRepository registrationRepository,
|
||||||
WebinarRepository webinarRepository,
|
WebinarRepository webinarRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
PlainTextSanitizer sanitizer) {
|
PlainTextSanitizer sanitizer,
|
||||||
|
com.softspm.webinar.repository.WebinarSpeakerRepository speakerRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.interactionRepository = interactionRepository;
|
this.interactionRepository = interactionRepository;
|
||||||
this.registrationRepository = registrationRepository;
|
this.registrationRepository = registrationRepository;
|
||||||
this.webinarRepository = webinarRepository;
|
this.webinarRepository = webinarRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.sanitizer = sanitizer;
|
this.sanitizer = sanitizer;
|
||||||
|
this.speakerRepository = speakerRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -265,6 +268,43 @@ public class TaskService {
|
|||||||
* Odczyt jest w serwisie, a nie w kontrolerze: @Transactional na metodzie
|
* Odczyt jest w serwisie, a nie w kontrolerze: @Transactional na metodzie
|
||||||
* wolanej z tej samej klasy nie dziala, bo samowywolanie omija proxy Springa.
|
* 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)
|
@Transactional(readOnly = true)
|
||||||
public ParticipantDetails participant(String confirmationToken) {
|
public ParticipantDetails participant(String confirmationToken) {
|
||||||
Registration registration = registrationRepository.findByConfirmationToken(confirmationToken)
|
Registration registration = registrationRepository.findByConfirmationToken(confirmationToken)
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ ARG VITE_API_URL=/api
|
|||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
RUN npm run build
|
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 ---
|
# --- etap 2: runtime ---
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
|||||||
Generated
+36
@@ -13,6 +13,7 @@
|
|||||||
"@jitsi/react-sdk": "^1.4.4",
|
"@jitsi/react-sdk": "^1.4.4",
|
||||||
"@livekit/components-react": "^2.9.24",
|
"@livekit/components-react": "^2.9.24",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@mui/x-charts": "^9.12.0",
|
"@mui/x-charts": "^9.12.0",
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"react-router-dom": "^6.28.1"
|
"react-router-dom": "^6.28.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -1108,6 +1110,25 @@
|
|||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@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": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "9.4.0",
|
"version": "9.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
|
||||||
@@ -2464,6 +2485,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@jitsi/react-sdk": "^1.4.4",
|
"@jitsi/react-sdk": "^1.4.4",
|
||||||
"@livekit/components-react": "^2.9.24",
|
"@livekit/components-react": "^2.9.24",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@mui/x-charts": "^9.12.0",
|
"@mui/x-charts": "^9.12.0",
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@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>
|
</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 === 'offer' && !history.speaker && <OfferTab token={token} />}
|
||||||
|
|
||||||
{tab === 'chat' ? (
|
{tab === 'chat' ? (
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
LiveKitRoom as LiveKitRoomProvider,
|
LiveKitRoom as LiveKitRoomProvider,
|
||||||
RoomAudioRenderer,
|
RoomAudioRenderer,
|
||||||
VideoConference,
|
VideoConference,
|
||||||
|
useLocalParticipant,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { RoomOptions } from 'livekit-client'
|
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.
|
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
|
||||||
@@ -36,10 +38,25 @@ export function LiveKitRoom({
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
token,
|
token,
|
||||||
onDisconnected,
|
onDisconnected,
|
||||||
|
initialAudio = false,
|
||||||
|
initialVideo = false,
|
||||||
|
onDevicesChange,
|
||||||
}: {
|
}: {
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
token: string
|
token: string
|
||||||
onDisconnected?: () => void
|
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>(
|
const options = useMemo<RoomOptions>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -71,15 +88,48 @@ export function LiveKitRoom({
|
|||||||
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
|
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
|
||||||
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
|
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
|
||||||
connect
|
connect
|
||||||
video={false}
|
video={initialVideo}
|
||||||
audio={false}
|
audio={initialAudio}
|
||||||
onDisconnected={onDisconnected}
|
onDisconnected={onDisconnected}
|
||||||
className="h-full w-full"
|
className="relative h-full w-full"
|
||||||
data-lk-theme="default"
|
data-lk-theme="default"
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<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 */}
|
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
</LiveKitRoomProvider>
|
</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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { useParams } from 'react-router-dom'
|
||||||
import { publicClient } from '../../api/publicClient'
|
import { publicClient } from '../../api/publicClient'
|
||||||
import { Icon } from '../../components/Icon'
|
import { Icon } from '../../components/Icon'
|
||||||
@@ -228,21 +228,17 @@ function MeetingRoom({
|
|||||||
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
|
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
|
||||||
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
|
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
|
||||||
*/
|
*/
|
||||||
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie
|
/*
|
||||||
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo
|
* Sygnal od prowadzacego mowi, ze uprawnienia MOGLY sie zmienic, ale nie
|
||||||
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie
|
* mowi, jakie sa: rozstrzyga to backend, ktory zna role z zapisu, oba tryby
|
||||||
// miedzy nimi tez wymuszalo odswiezenie.
|
* i indywidualnie przyznany glos. Pobieramy wiec ticket przy kazdej zmianie,
|
||||||
const next: 'none' | 'granted' | 'video' = openVideo
|
* a o przemontowaniu sali decyduje dopiero `key` zlozony z UPRAWNIEN
|
||||||
? 'video'
|
* (patrz miejsce renderowania `LiveKitRoom`), nie z samego tokenu.
|
||||||
: speak.granted || openMic
|
*/
|
||||||
? 'granted'
|
|
||||||
: 'none'
|
|
||||||
if (next === voiceState) return
|
|
||||||
setVoiceState(next)
|
|
||||||
if (join.embedded) void onRefreshTicket()
|
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(() => {
|
const src = useMemo(() => {
|
||||||
if (!join.embedded) return null
|
if (!join.embedded) return null
|
||||||
@@ -379,6 +375,16 @@ function MeetingRoom({
|
|||||||
* dziala jego gotowy komponent.
|
* dziala jego gotowy komponent.
|
||||||
*/
|
*/
|
||||||
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
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
|
// tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
|
||||||
if (!join.embedded) {
|
if (!join.embedded) {
|
||||||
return <ExternalVideo url={join.url} />
|
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">
|
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||||
<Suspense fallback={<VideoLoading />}>
|
<Suspense fallback={<VideoLoading />}>
|
||||||
{/*
|
{/*
|
||||||
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje
|
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
|
||||||
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera.
|
---------------------------------------------------------------
|
||||||
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie
|
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
|
||||||
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie
|
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
|
||||||
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana
|
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
|
||||||
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co
|
mikrofonu, dopoki uczestnik nie przeladuje strony.
|
||||||
robi przeladowanie, ale bez wychodzenia ze spotkania.
|
|
||||||
|
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>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,21 @@ export type TaskView = {
|
|||||||
createdAt: string
|
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 [tasks, setTasks] = useState<TaskView[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -129,16 +143,18 @@ export function TasksTab({ token, webinarId }: { token: string; webinarId: numbe
|
|||||||
)}
|
)}
|
||||||
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
|
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
|
||||||
|
|
||||||
<Button
|
{!readOnly && (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant={task.signedUp ? 'secondary' : 'primary'}
|
size="sm"
|
||||||
className="ml-auto"
|
variant={task.signedUp ? 'secondary' : 'primary'}
|
||||||
disabled={busy === task.id || (blocked && !task.signedUp)}
|
className="ml-auto"
|
||||||
onClick={() => toggle(task)}
|
disabled={busy === task.id || (blocked && !task.signedUp)}
|
||||||
>
|
onClick={() => toggle(task)}
|
||||||
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
|
>
|
||||||
</Button>
|
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.signedUp && (
|
{task.signedUp && (
|
||||||
|
|||||||
Reference in New Issue
Block a user