From 589c31784125c2d395e44484f17a7b99189a6a8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?=
Date: Tue, 29 Sep 2026 12:59:00 +0200
Subject: [PATCH] Kamera i ekran dla konkretnego uczestnika z panelu
prowadzenia
Karta "Na sali" dostala przelaczniki kamery i udostepniania ekranu przy
kazdym uczestniku (wylacznie LiveKit). Uprawnienia licza sie z trzech
niezaleznych praw (PublishRights: mikrofon, kamera, ekran) zamiast jednej
roli, w jednym miejscu dla tokenu i zmiany na zywo; serwer pokoju nadaje
i odbiera je bez ponownego wejscia na sale.
Kolumny camera_allowed i screen_allowed (V32), domyslnie false. Zgoda
indywidualna przezywa wylaczenie "wideo dla wszystkich". Uczestnik
dostaje komunikat, a przy ekranie ostrzezenie, ze sala zobaczy wszystko.
PublishRightsTest i PublishRightsTokenTest pilnuja, ze zgoda na jedno
zrodlo nie otwiera innego; oba sprawdzone w obie strony.
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 36 ++++-
.../softspm/webinar/domain/Registration.java | 14 ++
.../interaction/InteractionBroadcaster.java | 16 +++
.../webinar/video/LiveKitVideoProvider.java | 123 ++++++++----------
.../webinar/video/PublicJoinController.java | 7 +-
.../softspm/webinar/video/PublishRights.java | 42 ++++++
.../webinar/video/RoomPermissionSync.java | 14 +-
.../webinar/video/RoomPresenceController.java | 34 ++++-
.../webinar/video/RoomPresenceService.java | 20 ++-
.../softspm/webinar/video/VideoProvider.java | 7 +-
.../softspm/webinar/video/VideoService.java | 62 +++++++--
.../V32__participant_camera_screen.sql | 16 +++
.../webinar/video/PublishRightsTest.java | 107 +++++++++++++++
.../webinar/video/PublishRightsTokenTest.java | 86 ++++++++++++
frontend/src/lib/useInteractions.ts | 13 +-
frontend/src/pages/public/MeetingPage.tsx | 23 ++++
.../src/pages/webinars/RoomParticipants.tsx | 83 ++++++++++++
17 files changed, 603 insertions(+), 100 deletions(-)
create mode 100644 backend/src/main/java/com/softspm/webinar/video/PublishRights.java
create mode 100644 backend/src/main/resources/db/migration/V32__participant_camera_screen.sql
create mode 100644 backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java
create mode 100644 backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java
diff --git a/CLAUDE.md b/CLAUDE.md
index 39e81e6..0bde828 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,8 +75,12 @@ Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem:
| prelegent | publikuje obraz i dzwiek |
| uczestnik | tylko odbiera, ma czat i pytania |
| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora |
+| uczestnik z kamera albo ekranem | publikuje obraz lub ekran, wylacznie po indywidualnej zgodzie prowadzacego |
-Wideo uczestnikow jest zawsze wylaczone na poziomie uprawnien.
+Wideo i ekran uczestnikow sa **domyslnie** wylaczone na poziomie uprawnien.
+Wlacza je wylacznie prowadzacy: calej sali ("wideo dla wszystkich", bez ekranu)
+albo konkretnej osobie (kamera i ekran osobno, patrz "Indywidualna zgoda na
+kamere i ekran"). Uczestnik sam niczego sobie nie wlaczy.
## Zasady pracy
@@ -1683,6 +1687,33 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
+### Indywidualna zgoda na kamere i ekran
+
+Kolumny `registrations.camera_allowed` i `screen_allowed` (V32), przelaczniki
+w karcie "Na sali", endpoint `POST /admin/webinars/{id}/room/participants/{rid}/publish`.
+
+- **Trzy niezalezne prawa, nie jedna rola** (`PublishRights`: mikrofon, kamera,
+ ekran). Kombinacji jest osiem, a enum rol na kazda oznaczalby, ze latwo
+ przeoczyc, ktora odziedziczyla za duzo. `RoomRole` zostaje dla dostawcow
+ znajacych tylko role (`closestRole`, nigdy PRESENTER).
+- **Zgoda na jedno zrodlo nie otwiera innego.** Sam ekran to nie kamera, sama
+ kamera to nie mikrofon. Pilnuja tego `PublishRightsTest` (uprawnienia na zywo)
+ i `PublishRightsTokenTest` (lista zrodel w podpisanym tokenie), oba sprawdzone
+ w obie strony.
+- **Prawa licza sie w jednym miejscu** (`VideoService.participantRights`) dla
+ tokenu i dla zmiany na zywo. Zgoda indywidualna jest niezalezna od trybow sali:
+ wylaczenie "wideo dla wszystkich" nie odbiera kamery osobie, ktorej dano ja osobno.
+- **Tylko przy LiveKit.** Wylacznie tam serwer egzekwuje osobno kazde zrodlo;
+ przy innych dostawcach panel nie pokazuje tych przelacznikow.
+- **Kolejnosc: zapis, serwer pokoju, zdarzenie.** Zdarzenie `PUBLISH_CHANGED`
+ sluzy przegladarce do pokazania komunikatu i pobrania aktualnego biletu na
+ wypadek ponownego wejscia; samo uprawnienie zmienil juz serwer.
+- **Uczestnik z prawem do ekranu dostaje ostrzezenie**, ze sala zobaczy wszystko,
+ co pokaze, lacznie z powiadomieniami. Przycisku udostepniania nie da sie
+ przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa.
+- Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec
+ uczestnik z samym ekranem widzi tylko przycisk ekranu.
+
### Moderacja wyrazow
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy
@@ -2098,7 +2129,8 @@ ustawienie `VIDEO_PROVIDER`. Na start dwie: `NONE` i `DAILY`.
- **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie.
- **Wideo uczestnika jest wylaczone zawsze**, takze po podniesieniu do SPEAKER.
+ **Wideo uczestnika jest domyslnie wylaczone**, takze po podniesieniu do SPEAKER.
+ Kamere i ekran daje wylacznie prowadzacy (`PublishRights`).
- `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`:
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego
diff --git a/backend/src/main/java/com/softspm/webinar/domain/Registration.java b/backend/src/main/java/com/softspm/webinar/domain/Registration.java
index 8112e04..22c8a27 100644
--- a/backend/src/main/java/com/softspm/webinar/domain/Registration.java
+++ b/backend/src/main/java/com/softspm/webinar/domain/Registration.java
@@ -79,6 +79,20 @@ public class Registration {
@Column(name = "room_banned", nullable = false)
private boolean roomBanned;
+ /**
+ * Prowadzacy pozwolil tej osobie wlaczyc kamere, niezaleznie od glosu
+ * i od trybu "wideo dla wszystkich". Domyslnie false.
+ */
+ @Column(name = "camera_allowed", nullable = false)
+ private boolean cameraAllowed;
+
+ /**
+ * Prowadzacy pozwolil tej osobie udostepnic ekran. Domyslnie false:
+ * udostepniony ekran widzi cala sala, takze to, co pokazano przypadkiem.
+ */
+ @Column(name = "screen_allowed", nullable = false)
+ private boolean screenAllowed;
+
/**
* Rola w pokoju wideo.
*
diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java
index 13e8aee..700662d 100644
--- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java
+++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java
@@ -173,6 +173,22 @@ public class InteractionBroadcaster {
new ModerationEvent(kind, registrationId));
}
+ /**
+ * Zmiana indywidualnej zgody na kamere albo ekran.
+ *
+ * Uprawnienie zmienia juz serwer pokoju; zdarzenie sluzy temu, zeby
+ * przegladarka uczestnika pokazala, co dostal, i pobrala aktualny token na
+ * wypadek ponownego wejscia. Temat jest wspolny, wiec odbiorca filtruje po
+ * `registrationId`.
+ */
+ public void publishRightsChanged(Long webinarId, Long registrationId, boolean camera, boolean screen) {
+ messaging.convertAndSend(speakTopic(webinarId),
+ new PublishRightsEvent("PUBLISH_CHANGED", registrationId, camera, screen));
+ }
+
+ public record PublishRightsEvent(String kind, Long registrationId, boolean camera, boolean screen) {
+ }
+
/** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */
/**
* Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich
diff --git a/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java b/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java
index b8f436e..5c0288e 100644
--- a/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java
+++ b/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java
@@ -56,22 +56,6 @@ public class LiveKitVideoProvider implements VideoProvider {
private static final Logger log = LoggerFactory.getLogger(LiveKitVideoProvider.class);
- /**
- * Zrodla, ktore wolno publikowac uczestnikowi z glosem.
- *
- * Nazwy pochodza z protokolu LiveKit. Brak "camera" na tej liscie jest
- * **jedynym** miejscem, w ktorym zabraniamy uczestnikowi obrazu, i dziala
- * po stronie serwera.
- */
- private static final List AUDIO_ONLY = List.of("microphone");
-
- /** Uczestnik w trybie wideo dla wszystkich: kamera i mikrofon, bez ekranu. */
- private static final List CAMERA_AND_MIC = List.of("camera", "microphone");
-
- /** Prelegent: obraz, dzwiek i udostepnianie ekranu. */
- private static final List PRESENTER_SOURCES =
- List.of("camera", "microphone", "screen_share", "screen_share_audio");
-
/**
* Jak dlugo wazny jest token wejscia.
*
@@ -129,84 +113,87 @@ public class LiveKitVideoProvider implements VideoProvider {
token.setTtl(TOKEN_TTL_MILLIS);
token.addGrants(new RoomJoin(true), new RoomName(roomId));
- token.addGrants(grantsFor(request.role()));
+ token.addGrants(grantsFor(request));
return new JoinTicket(url, token.toJwt(), true, request.role());
}
/**
- * Uprawnienia odpowiadajace roli.
+ * Uprawnienia w tokenie.
*
- * Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej:
- * przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do
- * kamery po PRESENTER.
+ * Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie
+ * z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran
+ * bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma
+ * tylko prelegent.
*/
- private List grantsFor(RoomRole role) {
- return switch (role) {
- case VIEWER -> List.of(
- new CanSubscribe(true),
- new CanPublish(false),
- // dane pozwalaja bibliotece klienta na sygnalizacje
- // (np. podniesienie reki), bez prawa do audio i wideo
- new CanPublishData(true));
-
- case SPEAKER -> List.of(
- new CanSubscribe(true),
- new CanPublish(true),
- // sedno: publikacja ograniczona do mikrofonu.
- // Bez tej linii SPEAKER moglby wlaczyc kamere.
- new CanPublishSources(AUDIO_ONLY),
- new CanPublishData(true));
-
- case ACTIVE_PARTICIPANT -> List.of(
- new CanSubscribe(true),
- new CanPublish(true),
- // tryb "wideo dla wszystkich": kamera i mikrofon, ale bez
- // udostepniania ekranu - to zostaje przy prelegencie
- new CanPublishSources(CAMERA_AND_MIC),
- new CanPublishData(true));
-
- case PRESENTER -> List.of(
- new CanSubscribe(true),
- new CanPublish(true),
- new CanPublishSources(PRESENTER_SOURCES),
- new CanPublishData(true),
- new RoomAdmin(true),
- new RoomRecord(true));
- };
+ private List grantsFor(JoinRequest request) {
+ PublishRights rights = request.rights();
+ List grants = new java.util.ArrayList<>(List.of(
+ new CanSubscribe(true),
+ new CanPublish(rights.any()),
+ // dane pozwalaja bibliotece klienta na sygnalizacje,
+ // bez prawa do audio i wideo
+ new CanPublishData(true)));
+ if (rights.any()) {
+ // sedno: jawna lista zrodel. Bez niej `canPublish` znaczyloby
+ // "wszystko", czyli takze kamere i ekran
+ grants.add(new CanPublishSources(sourcesFor(rights)));
+ }
+ if (request.role() == RoomRole.PRESENTER) {
+ grants.add(new RoomAdmin(true));
+ grants.add(new RoomRecord(true));
+ }
+ return grants;
}
/**
* Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
*
- * Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu zmienia
- * uprawnienia uczestnika juz polaczonego, bez ponownego wejscia na sale.
- * Zrodla publikacji pochodza z tych samych stalych co `grantsFor`, wiec
+ * Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo
+ * ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego
+ * wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec
* token i zmiana na zywo nie moga sie rozjechac.
*
* UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie
* zrodla", czyli takze kamere i ekran. Dlatego lista jest zawsze jawna,
- * a VIEWER ma `canPublish=false`.
+ * a brak praw daje `canPublish=false`.
*/
- LivekitModels.ParticipantPermission permissionFor(RoomRole role) {
+ LivekitModels.ParticipantPermission permissionFor(PublishRights rights) {
var builder = LivekitModels.ParticipantPermission.newBuilder()
.setCanSubscribe(true)
.setCanPublishData(true)
- .setCanPublish(role != RoomRole.VIEWER);
- for (String source : sourcesFor(role)) {
+ .setCanPublish(rights.any());
+ for (String source : sourcesFor(rights)) {
builder.addCanPublishSources(trackSource(source));
}
return builder.build();
}
- /** Zrodla, ktore rola moze publikowac. Pusta lista przy VIEWER. */
+ LivekitModels.ParticipantPermission permissionFor(RoomRole role) {
+ return permissionFor(PublishRights.of(role));
+ }
+
+ /**
+ * Zrodla publikacji. Nazwy pochodza z protokolu LiveKit.
+ *
+ * Brak "camera" i "screen_share" na tej liscie jest JEDYNYM miejscem,
+ * w ktorym zabraniamy uczestnikowi obrazu i ekranu, i dziala po stronie
+ * serwera. Pusta lista przy braku praw.
+ */
+ static List sourcesFor(PublishRights rights) {
+ List sources = new java.util.ArrayList<>();
+ if (rights.audio()) sources.add("microphone");
+ if (rights.camera()) sources.add("camera");
+ if (rights.screen()) {
+ sources.add("screen_share");
+ // dzwiek udostepnianej karty, np. film pokazywany na ekranie
+ sources.add("screen_share_audio");
+ }
+ return List.copyOf(sources);
+ }
+
static List sourcesFor(RoomRole role) {
- return switch (role) {
- case VIEWER -> List.of();
- case SPEAKER -> AUDIO_ONLY;
- case ACTIVE_PARTICIPANT -> CAMERA_AND_MIC;
- case PRESENTER -> PRESENTER_SOURCES;
- };
+ return sourcesFor(PublishRights.of(role));
}
static LivekitModels.TrackSource trackSource(String name) {
diff --git a/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java b/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java
index d936130..fb15716 100644
--- a/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java
+++ b/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java
@@ -91,6 +91,8 @@ public class PublicJoinController {
*/
boolean canPublishVideo,
boolean canPublishAudio,
+ /** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */
+ boolean canShareScreen,
/**
* Publiczny klucz klienta do osadzenia (Zoom SDK Key).
* Pusty dla dostawcow, ktorzy go nie potrzebuja.
@@ -111,8 +113,9 @@ public class PublicJoinController {
result.timezone(),
result.provider(),
result.roomId(),
- result.ticket().role().canPublishVideo(),
- result.ticket().role().canPublishAudio(),
+ result.rights().camera(),
+ result.rights().audio(),
+ result.rights().screen(),
result.ticket().clientKey());
}
}
diff --git a/backend/src/main/java/com/softspm/webinar/video/PublishRights.java b/backend/src/main/java/com/softspm/webinar/video/PublishRights.java
new file mode 100644
index 0000000..6b38024
--- /dev/null
+++ b/backend/src/main/java/com/softspm/webinar/video/PublishRights.java
@@ -0,0 +1,42 @@
+package com.softspm.webinar.video;
+
+/**
+ * Co uczestnik moze nadawac: mikrofon, kamera, udostepnienie ekranu.
+ *
+ * TRZY NIEZALEZNE PRAWA, NIE JEDNA ROLA
+ * ---------------------------------------------------------------------------
+ * `RoomRole` opisuje stale zestawy (widz, z glosem, wideo dla wszystkich,
+ * prelegent). Prowadzacy moze jednak dac konkretnej osobie sama kamere albo
+ * sam ekran, niezaleznie od tego, czy ma glos. Takich kombinacji jest osiem,
+ * a dopisywanie roli na kazda oznaczaloby enum, w ktorym latwo przeoczyc,
+ * ze ktoras odziedziczyla za duzo.
+ *
+ * Rola zostaje dla dostawcow, ktorzy znaja tylko role (Daily, Jitsi, Zoom):
+ * dostaja najblizsza (`closestRole`). Dokladne prawa egzekwuje LiveKit,
+ * lista zrodel w tokenie i w zmianie na zywo.
+ */
+public record PublishRights(boolean audio, boolean camera, boolean screen) {
+
+ public static final PublishRights NONE = new PublishRights(false, false, false);
+
+ /** Prawa wynikajace z samej roli. Ekran wylacznie u prelegenta. */
+ public static PublishRights of(RoomRole role) {
+ return new PublishRights(role.canPublishAudio(), role.canPublishVideo(),
+ role == RoomRole.PRESENTER);
+ }
+
+ /** Czy wolno nadawac cokolwiek. */
+ public boolean any() {
+ return audio || camera || screen;
+ }
+
+ /**
+ * Rola najblizsza tym prawom, dla dostawcow bez rozroznienia zrodel.
+ * Nigdy PRESENTER: uczestnik z zapisu nie dostaje uprawnien moderatora.
+ */
+ public RoomRole closestRole() {
+ if (camera) return RoomRole.ACTIVE_PARTICIPANT;
+ if (audio) return RoomRole.SPEAKER;
+ return RoomRole.VIEWER;
+ }
+}
diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java b/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java
index 6a25ade..f42678a 100644
--- a/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java
+++ b/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java
@@ -87,9 +87,9 @@ public class RoomPermissionSync {
// nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu
return;
}
- RoomRole role = videoService.participantRoles(webinarId, List.of(registrationId))
- .getOrDefault(registrationId, RoomRole.VIEWER);
- apply(client, room, response.body(), role);
+ PublishRights rights = videoService.participantRights(webinarId, List.of(registrationId))
+ .getOrDefault(registrationId, PublishRights.NONE);
+ apply(client, room, response.body(), rights);
} catch (Exception e) {
log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}",
registrationId, webinarId, e.getMessage());
@@ -121,7 +121,7 @@ public class RoomPermissionSync {
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
.filter(Objects::nonNull)
.toList();
- Map roles = videoService.participantRoles(webinarId, ids);
+ Map rights = videoService.participantRights(webinarId, ids);
int changed = 0;
for (LivekitModels.ParticipantInfo info : participants) {
@@ -129,7 +129,7 @@ public class RoomPermissionSync {
// prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia
if (id == null) continue;
try {
- if (apply(client, room, info, roles.getOrDefault(id, RoomRole.VIEWER))) changed++;
+ if (apply(client, room, info, rights.getOrDefault(id, PublishRights.NONE))) changed++;
} catch (Exception e) {
log.warn("Uprawnienia: {} w webinarze {} nieudane: {}",
info.getIdentity(), webinarId, e.getMessage());
@@ -147,8 +147,8 @@ public class RoomPermissionSync {
* @return czy cokolwiek zostalo zmienione
*/
private boolean apply(RoomServiceClient client, String room,
- LivekitModels.ParticipantInfo info, RoomRole role) throws java.io.IOException {
- LivekitModels.ParticipantPermission target = liveKit.permissionFor(role);
+ LivekitModels.ParticipantInfo info, PublishRights rights) throws java.io.IOException {
+ LivekitModels.ParticipantPermission target = liveKit.permissionFor(rights);
if (samePermission(info.getPermission(), target)) {
return false;
}
diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java
index d854e1b..6f62282 100644
--- a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java
+++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java
@@ -30,13 +30,19 @@ public class RoomPresenceController {
private final RoomPresenceService presence;
private final InteractionService interactions;
private final InteractionBroadcaster broadcaster;
+ private final VideoService videoService;
+ private final RoomPermissionSync permissionSync;
public RoomPresenceController(RoomPresenceService presence,
InteractionService interactions,
- InteractionBroadcaster broadcaster) {
+ InteractionBroadcaster broadcaster,
+ VideoService videoService,
+ RoomPermissionSync permissionSync) {
this.presence = presence;
this.interactions = interactions;
this.broadcaster = broadcaster;
+ this.videoService = videoService;
+ this.permissionSync = permissionSync;
}
@GetMapping
@@ -89,6 +95,32 @@ public class RoomPresenceController {
public record RemoveRequest(@NotBlank String identity) {
}
+ /**
+ * Indywidualna zgoda na kamere i udostepnienie ekranu.
+ *
+ * Kolejnosc ma znaczenie: najpierw zapis w bazie (transakcja zatwierdzona),
+ * potem zmiana na serwerze pokoju, na koncu zdarzenie dla przegladarki.
+ * Odwrotnie przegladarka pobralaby token z jeszcze starymi prawami.
+ *
+ * Dziala wylacznie przy LiveKit: tylko tam serwer egzekwuje osobno kamere
+ * i ekran. Przy innych dostawcach panel nie pokazuje tych przelacznikow.
+ */
+ @PostMapping("/{registrationId}/publish")
+ @Operation(summary = "Zgoda uczestnika na kamere i udostepnienie ekranu")
+ public VideoService.IndividualRights setPublishRights(@PathVariable Long id,
+ @PathVariable Long registrationId,
+ @RequestBody PublishRequest request) {
+ VideoService.IndividualRights rights =
+ videoService.setIndividualRights(id, registrationId, request.camera(), request.screen());
+ permissionSync.syncParticipant(id, registrationId);
+ broadcaster.publishRightsChanged(id, registrationId, rights.camera(), rights.screen());
+ return rights;
+ }
+
+ /** Null w polu oznacza "bez zmiany". */
+ public record PublishRequest(Boolean camera, Boolean screen) {
+ }
+
/**
* @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
* dostawcy bez tej operacji albo gdy osoba juz wyszla
diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java
index 6eb124f..eaa5068 100644
--- a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java
+++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java
@@ -272,15 +272,18 @@ public class RoomPresenceService {
if (registration != null) {
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
- registration.isChatMuted(), registration.isRoomBanned()));
+ registration.isChatMuted(), registration.isRoomBanned(),
+ registration.isCameraAllowed(), registration.isScreenAllowed()));
} else if (speaker != null) {
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
- null, speaker.getEmail(), joinedAt, audio, video, screen, false, false));
+ null, speaker.getEmail(), joinedAt, audio, video, screen, false, false,
+ true, true));
} else {
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
String name = info.getName().isBlank() ? identity : info.getName();
result.add(new ParticipantView(identity, name, KIND_OTHER,
- null, null, joinedAt, audio, video, screen, false, false));
+ null, null, joinedAt, audio, video, screen, false, false,
+ false, false));
}
}
return result;
@@ -299,10 +302,11 @@ public class RoomPresenceService {
if (r != null) {
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
- r.isChatMuted(), r.isRoomBanned());
+ r.isChatMuted(), r.isRoomBanned(), r.isCameraAllowed(), r.isScreenAllowed());
} else if (s != null) {
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
- null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false);
+ null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false,
+ true, true);
} else {
continue;
}
@@ -374,7 +378,11 @@ public class RoomPresenceService {
public record ParticipantView(String identity, String name, String kind, Long registrationId,
String email, OffsetDateTime joinedAt,
boolean audio, boolean video, boolean screen,
- boolean chatMuted, boolean roomBanned) {
+ boolean chatMuted, boolean roomBanned,
+ /** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */
+ boolean cameraAllowed,
+ /** Indywidualna zgoda prowadzacego na ekran (prelegent zawsze true). */
+ boolean screenAllowed) {
}
public record BannedView(Long registrationId, String name, String email) {
diff --git a/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java b/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java
index 6e2a5c5..c1ba7a3 100644
--- a/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java
+++ b/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java
@@ -121,7 +121,12 @@ public interface VideoProvider {
}
/** Kto wchodzi do pokoju i z jakimi uprawnieniami. */
- record JoinRequest(String displayName, String userId, RoomRole role) {
+ record JoinRequest(String displayName, String userId, RoomRole role, PublishRights rights) {
+
+ /** Prawa wynikajace z samej roli, bez indywidualnych zgod prowadzacego. */
+ JoinRequest(String displayName, String userId, RoomRole role) {
+ this(displayName, userId, role, PublishRights.of(role));
+ }
}
/**
diff --git a/backend/src/main/java/com/softspm/webinar/video/VideoService.java b/backend/src/main/java/com/softspm/webinar/video/VideoService.java
index 9f2884b..100ac70 100644
--- a/backend/src/main/java/com/softspm/webinar/video/VideoService.java
+++ b/backend/src/main/java/com/softspm/webinar/video/VideoService.java
@@ -264,14 +264,15 @@ public class VideoService {
* bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
* spotkania przenosi na polaczonych `RoomPermissionSync`.
*/
- RoomRole role = participantRole(webinar, registration);
+ PublishRights rights = participantRights(webinar, registration);
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar,
- new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(), role));
+ new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(),
+ rights.closestRole(), rights));
return new JoinResult(ticket, webinar.getTitle(), displayName, window,
webinar.getStartsAt(), webinar.getTimezone(),
- provider.name(), webinar.getRoomId());
+ provider.name(), webinar.getRoomId(), rights);
}
/**
@@ -299,24 +300,63 @@ public class VideoService {
}
/**
- * Role wskazanych uczestnikow, wylacznie tych nalezacych do webinaru.
+ * Co uczestnik moze nadawac: rola sali (glos, oba tryby) plus indywidualne
+ * zgody prowadzacego na kamere i ekran.
+ *
+ * Zgody indywidualne sa NIEZALEZNE od trybow: wylaczenie "wideo dla
+ * wszystkich" nie odbiera kamery osobie, ktorej prowadzacy dal ja osobno,
+ * tak samo jak wylaczenie otwartego mikrofonu nie odbiera przyznanego glosu.
+ */
+ public static PublishRights participantRights(Webinar webinar, Registration registration) {
+ RoomRole base = participantRole(webinar, registration);
+ return new PublishRights(
+ base.canPublishAudio(),
+ base.canPublishVideo() || registration.isCameraAllowed(),
+ registration.isScreenAllowed());
+ }
+
+ /**
+ * Prawa wskazanych uczestnikow, wylacznie tych nalezacych do webinaru.
*
* Identyfikatory pochodza z pokoju u dostawcy, wiec zapis z innego
* webinaru jest pomijany, a nie podnoszony.
*/
@Transactional(readOnly = true)
- public Map participantRoles(Long webinarId, java.util.Collection registrationIds) {
+ public Map participantRights(Long webinarId, java.util.Collection registrationIds) {
Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
- Map roles = new java.util.HashMap<>();
+ Map rights = new java.util.HashMap<>();
for (Registration registration : registrationRepository.findAllById(registrationIds)) {
if (registration.getWebinar().getId().equals(webinarId)
&& registration.getStatus() == RegistrationStatus.CONFIRMED
&& !registration.isRoomBanned()) {
- roles.put(registration.getId(), participantRole(webinar, registration));
+ rights.put(registration.getId(), participantRights(webinar, registration));
}
}
- return roles;
+ return rights;
+ }
+
+ /**
+ * Indywidualna zgoda na kamere i ekran, z panelu prowadzenia.
+ *
+ * Null oznacza "bez zmiany", zeby przelacznik kamery nie ruszal ekranu.
+ * Zapis musi nalezec do webinaru z adresu: identyfikator przychodzi
+ * z zewnatrz.
+ */
+ @Transactional
+ public IndividualRights setIndividualRights(Long webinarId, Long registrationId,
+ Boolean camera, Boolean screen) {
+ Registration registration = registrationRepository.findById(registrationId)
+ .filter(r -> r.getWebinar().getId().equals(webinarId))
+ .orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru"));
+ if (camera != null) registration.setCameraAllowed(camera);
+ if (screen != null) registration.setScreenAllowed(screen);
+ registrationRepository.save(registration);
+ return new IndividualRights(registration.getId(),
+ registration.isCameraAllowed(), registration.isScreenAllowed());
+ }
+
+ public record IndividualRights(Long registrationId, boolean camera, boolean screen) {
}
/**
@@ -342,7 +382,7 @@ public class VideoService {
return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar),
webinar.getStartsAt(), webinar.getTimezone(),
- provider.name(), webinar.getRoomId());
+ provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER));
}
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
@@ -382,7 +422,9 @@ public class VideoService {
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
String provider,
/** Identyfikator pokoju u dostawcy. */
- String roomId) {
+ String roomId,
+ /** Co wolno nadawac: mikrofon, kamera, ekran. */
+ PublishRights rights) {
}
/**
diff --git a/backend/src/main/resources/db/migration/V32__participant_camera_screen.sql b/backend/src/main/resources/db/migration/V32__participant_camera_screen.sql
new file mode 100644
index 0000000..75559e3
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V32__participant_camera_screen.sql
@@ -0,0 +1,16 @@
+-- Indywidualne prawo uczestnika do kamery i do udostepnienia ekranu.
+--
+-- Prowadzacy wlacza je konkretnej osobie z panelu prowadzenia, niezaleznie
+-- od glosu (`room_role`) i od trybow calej sali (`open_mic`, `open_video`).
+-- Osobne kolumny, a nie kolejne wartosci `room_role`: prawa sa niezalezne,
+-- wiec wartosci na kazda kombinacje byloby osiem.
+--
+-- Domyslnie false: wideo i ekran uczestnika sa wylaczone, dopoki prowadzacy
+-- swiadomie ich nie przyzna. Udostepniony ekran widzi cala sala, lacznie
+-- z tym, co uczestnik pokaze przez przypadek.
+
+ALTER TABLE registrations
+ ADD COLUMN camera_allowed boolean NOT NULL DEFAULT false;
+
+ALTER TABLE registrations
+ ADD COLUMN screen_allowed boolean NOT NULL DEFAULT false;
diff --git a/backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java
new file mode 100644
index 0000000..1424a34
--- /dev/null
+++ b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java
@@ -0,0 +1,107 @@
+package com.softspm.webinar.video;
+
+import com.softspm.webinar.domain.Registration;
+import com.softspm.webinar.domain.RegistrationRoomRole;
+import com.softspm.webinar.domain.Webinar;
+import livekit.LivekitModels.TrackSource;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Indywidualna zgoda prowadzacego na kamere i ekran.
+ *
+ * Najwazniejsze sa dwie granice: zgoda na jedno zrodlo nie moze otwierac
+ * innego (sam ekran to nie kamera, sama kamera to nie mikrofon), a uczestnik
+ * z zapisu nigdy nie dostaje uprawnien prelegenta.
+ */
+class PublishRightsTest {
+
+ private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null);
+
+ private static Webinar webinar(boolean openMic, boolean openVideo) {
+ Webinar webinar = new Webinar();
+ webinar.setOpenMic(openMic);
+ webinar.setOpenVideo(openVideo);
+ return webinar;
+ }
+
+ private static Registration registration(RegistrationRoomRole role, boolean camera, boolean screen) {
+ Registration registration = new Registration();
+ registration.setRoomRole(role);
+ registration.setCameraAllowed(camera);
+ registration.setScreenAllowed(screen);
+ return registration;
+ }
+
+ private Set sources(PublishRights rights) {
+ return Set.copyOf(provider.permissionFor(rights).getCanPublishSourcesList());
+ }
+
+ @Test
+ void viewerWithoutGrantsPublishesNothing() {
+ PublishRights rights = VideoService.participantRights(
+ webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, false));
+ assertThat(rights).isEqualTo(PublishRights.NONE);
+ assertThat(provider.permissionFor(rights).getCanPublish()).isFalse();
+ }
+
+ @Test
+ void cameraGrantDoesNotOpenMicrophone() {
+ PublishRights rights = VideoService.participantRights(
+ webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
+ assertThat(sources(rights)).containsExactly(TrackSource.CAMERA);
+ }
+
+ @Test
+ void screenGrantDoesNotOpenCamera() {
+ PublishRights rights = VideoService.participantRights(
+ webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, true));
+ assertThat(sources(rights))
+ .containsExactlyInAnyOrder(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
+ }
+
+ @Test
+ void grantsAddUpWithVoice() {
+ PublishRights rights = VideoService.participantRights(
+ webinar(false, false), registration(RegistrationRoomRole.SPEAKER, true, true));
+ assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.MICROPHONE, TrackSource.CAMERA,
+ TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
+ }
+
+ @Test
+ void individualCameraSurvivesTurningOffVideoForAll() {
+ // tryb "wideo dla wszystkich" wylaczony, ale prowadzacy dal kamere tej osobie
+ PublishRights rights = VideoService.participantRights(
+ webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
+ assertThat(rights.camera()).isTrue();
+ }
+
+ @Test
+ void videoForAllStillGivesNoScreen() {
+ PublishRights rights = VideoService.participantRights(
+ webinar(false, true), registration(RegistrationRoomRole.VIEWER, false, false));
+ assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE);
+ }
+
+ @Test
+ void participantNeverGetsPresenterRole() {
+ for (boolean audio : new boolean[] {false, true}) {
+ for (boolean camera : new boolean[] {false, true}) {
+ for (boolean screen : new boolean[] {false, true}) {
+ assertThat(new PublishRights(audio, camera, screen).closestRole())
+ .isNotEqualTo(RoomRole.PRESENTER);
+ }
+ }
+ }
+ }
+
+ @Test
+ void presenterKeepsEverything() {
+ assertThat(sources(PublishRights.of(RoomRole.PRESENTER))).containsExactlyInAnyOrder(
+ TrackSource.MICROPHONE, TrackSource.CAMERA,
+ TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
+ }
+}
diff --git a/backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java
new file mode 100644
index 0000000..7947fc9
--- /dev/null
+++ b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java
@@ -0,0 +1,86 @@
+package com.softspm.webinar.video;
+
+import com.softspm.webinar.domain.Webinar;
+import com.softspm.webinar.settings.SettingKey;
+import com.softspm.webinar.settings.SettingsService;
+import io.jsonwebtoken.Jwts;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Token LiveKit wydany dla indywidualnych zgod na kamere i ekran.
+ *
+ * Bez kontekstu Springa i bez bazy: ustawienia sa podstawione, a token
+ * rozkladany tak samo jak w `ProviderTokenTest`. Sprawdza to, co faktycznie
+ * trafia do serwera LiveKit, czyli liste zrodel w podpisanym tokenie, a nie
+ * tylko obiekt uprawnien po naszej stronie.
+ */
+class PublishRightsTokenTest {
+
+ private static final String SECRET = "sekret-testowy-livekit-co-najmniej-32-znaki";
+
+ private final LiveKitVideoProvider provider = new LiveKitVideoProvider(settings());
+
+ private static SettingsService settings() {
+ SettingsService settings = mock(SettingsService.class);
+ when(settings.get(SettingKey.LIVEKIT_URL)).thenReturn("wss://livekit.example.test");
+ when(settings.get(SettingKey.LIVEKIT_API_KEY)).thenReturn("klucz-testowy");
+ when(settings.get(SettingKey.LIVEKIT_API_SECRET)).thenReturn(SECRET);
+ return settings;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map grants(PublishRights rights) {
+ Webinar webinar = new Webinar();
+ webinar.setId(4242L);
+ String jwt = provider.createJoinTicket(webinar, new VideoProvider.JoinRequest(
+ "Jan Testowy", "reg-1", rights.closestRole(), rights)).token();
+ var claims = Jwts.parser()
+ .verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
+ .build()
+ .parseSignedClaims(jwt)
+ .getPayload();
+ return (Map) claims.get("video");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List sources(Map video) {
+ Object raw = video.get("canPublishSources");
+ return raw == null ? List.of() : (List) raw;
+ }
+
+ @Test
+ void screenOnlyTokenHasNoCameraAndNoMicrophone() {
+ var video = grants(new PublishRights(false, false, true));
+ assertThat(video.get("canPublish")).isEqualTo(true);
+ assertThat(sources(video)).containsExactlyInAnyOrder("screen_share", "screen_share_audio");
+ }
+
+ @Test
+ void cameraOnlyTokenHasNoMicrophone() {
+ var video = grants(new PublishRights(false, true, false));
+ assertThat(sources(video)).containsExactly("camera");
+ }
+
+ @Test
+ void noRightsMeansNoPublishing() {
+ var video = grants(PublishRights.NONE);
+ assertThat(video.get("canPublish")).isEqualTo(false);
+ assertThat(sources(video)).isEmpty();
+ }
+
+ @Test
+ void participantTokenNeverGetsRoomAdmin() {
+ var video = grants(new PublishRights(true, true, true));
+ assertThat(video.get("roomAdmin")).isNotEqualTo(true);
+ assertThat(video.get("roomRecord")).isNotEqualTo(true);
+ }
+}
diff --git a/frontend/src/lib/useInteractions.ts b/frontend/src/lib/useInteractions.ts
index d8f54da..c969a40 100644
--- a/frontend/src/lib/useInteractions.ts
+++ b/frontend/src/lib/useInteractions.ts
@@ -81,10 +81,16 @@ type History = {
questions: InteractionView[]
}
-/** Zdarzenie moderacji uczestnika: wyciszenie/blokada. */
+/**
+ * Zdarzenie dotyczace jednego uczestnika: wyciszenie, blokada albo zmiana
+ * indywidualnej zgody na kamere i ekran (`PUBLISH_CHANGED`).
+ */
export type ModerationEvent = {
- kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED'
+ kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
registrationId: number
+ /** Tylko przy PUBLISH_CHANGED. */
+ camera?: boolean
+ screen?: boolean
}
type Event = { kind: string; payload: InteractionView }
@@ -245,7 +251,8 @@ export function useInteractions(
}
if (
event.kind === 'MUTED' || event.kind === 'UNMUTED' ||
- event.kind === 'BANNED' || event.kind === 'UNBANNED'
+ event.kind === 'BANNED' || event.kind === 'UNBANNED' ||
+ event.kind === 'PUBLISH_CHANGED'
) {
const mod = event as unknown as ModerationEvent
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
diff --git a/frontend/src/pages/public/MeetingPage.tsx b/frontend/src/pages/public/MeetingPage.tsx
index 78a3914..3b97df7 100644
--- a/frontend/src/pages/public/MeetingPage.tsx
+++ b/frontend/src/pages/public/MeetingPage.tsx
@@ -75,6 +75,8 @@ type JoinView = {
*/
canPublishVideo: boolean
canPublishAudio: boolean
+ /** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
+ canShareScreen: boolean
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
clientKey: string | null
}
@@ -369,6 +371,24 @@ function MeetingRoom({
)}
+ {/*
+ Indywidualna zgoda prowadzacego. Przyciski kamery i ekranu pojawiaja
+ sie pod obrazem same, bo uprawnienie zmienia serwer; ta informacja
+ mowi uczestnikowi, ze to nie przypadek, i ostrzega przed ekranem.
+ */}
+ {!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canPublishVideo && !openVideo && (
+
+ Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem.
+
+ Prowadzący pozwolił Ci udostępnić ekran. Wszyscy uczestnicy zobaczą to, co pokażesz,
+ także powiadomienia i pocztę. Przed udostępnieniem zamknij to, czego nie chcesz pokazywać,
+ albo udostępnij tylko jedno okno.
+
+ )}
+
{
if (event.kind === 'BANNED') setBanned(true)
if (event.kind === 'UNBANNED') setBanned(false)
+ // uprawnienie zmienil juz serwer; nowy bilet niesie je do komunikatu
+ // i na wypadek ponownego wejscia po zerwaniu polaczenia
+ if (event.kind === 'PUBLISH_CHANGED' && join.embedded) void onRefreshTicket()
}}
onHistoryLoaded={(history) => {
setOpenMic(history.openMic)
diff --git a/frontend/src/pages/webinars/RoomParticipants.tsx b/frontend/src/pages/webinars/RoomParticipants.tsx
index 4cfdbb9..e9aeaea 100644
--- a/frontend/src/pages/webinars/RoomParticipants.tsx
+++ b/frontend/src/pages/webinars/RoomParticipants.tsx
@@ -21,6 +21,10 @@ type Participant = {
screen: boolean
chatMuted: boolean
roomBanned: boolean
+ /** Indywidualna zgoda prowadzacego na kamere. */
+ cameraAllowed: boolean
+ /** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
+ screenAllowed: boolean
}
type Presence = {
@@ -143,6 +147,38 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
}
}
+ /**
+ * Zgoda na kamere albo ekran dla jednej osoby.
+ *
+ * Bez okna potwierdzenia: to nie kasuje ani nie blokuje, a cofniecie jest
+ * jednym kliknieciem i dziala od razu, bo uprawnienie zmienia serwer.
+ */
+ async function togglePublish(participant: Participant, what: 'camera' | 'screen') {
+ if (participant.registrationId === null) return
+ const next = what === 'camera' ? !participant.cameraAllowed : !participant.screenAllowed
+ setBusy(participant.identity)
+ try {
+ await client.post(
+ `/admin/webinars/${webinarId}/room/participants/${participant.registrationId}/publish`,
+ { [what]: next },
+ )
+ toasts.success(
+ what === 'camera'
+ ? next
+ ? `${participant.name} może włączyć kamerę`
+ : `Wyłączono kamerę: ${participant.name}`
+ : next
+ ? `${participant.name} może udostępnić ekran`
+ : `Wyłączono udostępnianie ekranu: ${participant.name}`,
+ )
+ await refresh()
+ } catch (error) {
+ toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień'))
+ } finally {
+ setBusy(null)
+ }
+ }
+
async function unban(registrationId: number, name: string) {
setBusy(`ban-${registrationId}`)
try {
@@ -263,6 +299,53 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
{participant.kind !== 'SPEAKER' && (
+ {/*
+ Kamera i ekran tylko przy LiveKit: wylacznie tam serwer
+ egzekwuje osobno kazde zrodlo. Przy innych dostawcach
+ przycisk obiecywalby cos, czego nikt nie pilnuje.
+ */}
+ {participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
+ <>
+
+
+ >
+ )}
{participant.kind === 'PARTICIPANT' && (