Kamera i ekran dla konkretnego uczestnika z panelu prowadzenia
Deploy PROD / deploy (push) Successful in 26s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:59:00 +02:00
parent dda54f00a1
commit 589c317841
17 changed files with 603 additions and 100 deletions
+34 -2
View File
@@ -75,8 +75,12 @@ Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem:
| prelegent | publikuje obraz i dzwiek | | prelegent | publikuje obraz i dzwiek |
| uczestnik | tylko odbiera, ma czat i pytania | | uczestnik | tylko odbiera, ma czat i pytania |
| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora | | 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 ## 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. czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. - 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 ### Moderacja wyrazow
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy 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 - **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie. 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`: - `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`:
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego
@@ -79,6 +79,20 @@ public class Registration {
@Column(name = "room_banned", nullable = false) @Column(name = "room_banned", nullable = false)
private boolean roomBanned; 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. * Rola w pokoju wideo.
* *
@@ -173,6 +173,22 @@ public class InteractionBroadcaster {
new ModerationEvent(kind, registrationId)); 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`. */ /** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */
/** /**
* Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich * Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich
@@ -56,22 +56,6 @@ public class LiveKitVideoProvider implements VideoProvider {
private static final Logger log = LoggerFactory.getLogger(LiveKitVideoProvider.class); 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<String> AUDIO_ONLY = List.of("microphone");
/** Uczestnik w trybie wideo dla wszystkich: kamera i mikrofon, bez ekranu. */
private static final List<String> CAMERA_AND_MIC = List.of("camera", "microphone");
/** Prelegent: obraz, dzwiek i udostepnianie ekranu. */
private static final List<String> PRESENTER_SOURCES =
List.of("camera", "microphone", "screen_share", "screen_share_audio");
/** /**
* Jak dlugo wazny jest token wejscia. * Jak dlugo wazny jest token wejscia.
* *
@@ -129,84 +113,87 @@ public class LiveKitVideoProvider implements VideoProvider {
token.setTtl(TOKEN_TTL_MILLIS); token.setTtl(TOKEN_TTL_MILLIS);
token.addGrants(new RoomJoin(true), new RoomName(roomId)); 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()); return new JoinTicket(url, token.toJwt(), true, request.role());
} }
/** /**
* Uprawnienia odpowiadajace roli. * Uprawnienia w tokenie.
* *
* Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej: * Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie
* przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do * z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran
* kamery po PRESENTER. * bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma
* tylko prelegent.
*/ */
private List<io.livekit.server.VideoGrant> grantsFor(RoomRole role) { private List<io.livekit.server.VideoGrant> grantsFor(JoinRequest request) {
return switch (role) { PublishRights rights = request.rights();
case VIEWER -> List.of( List<io.livekit.server.VideoGrant> grants = new java.util.ArrayList<>(List.of(
new CanSubscribe(true), new CanSubscribe(true),
new CanPublish(false), new CanPublish(rights.any()),
// dane pozwalaja bibliotece klienta na sygnalizacje // dane pozwalaja bibliotece klienta na sygnalizacje,
// (np. podniesienie reki), bez prawa do audio i wideo // bez prawa do audio i wideo
new CanPublishData(true)); new CanPublishData(true)));
if (rights.any()) {
case SPEAKER -> List.of( // sedno: jawna lista zrodel. Bez niej `canPublish` znaczyloby
new CanSubscribe(true), // "wszystko", czyli takze kamere i ekran
new CanPublish(true), grants.add(new CanPublishSources(sourcesFor(rights)));
// sedno: publikacja ograniczona do mikrofonu. }
// Bez tej linii SPEAKER moglby wlaczyc kamere. if (request.role() == RoomRole.PRESENTER) {
new CanPublishSources(AUDIO_ONLY), grants.add(new RoomAdmin(true));
new CanPublishData(true)); grants.add(new RoomRecord(true));
}
case ACTIVE_PARTICIPANT -> List.of( return grants;
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));
};
} }
/** /**
* Te same uprawnienia co w tokenie, w postaci do zmiany na zywo. * Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
* *
* Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu zmienia * Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo
* uprawnienia uczestnika juz polaczonego, bez ponownego wejscia na sale. * ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego
* Zrodla publikacji pochodza z tych samych stalych co `grantsFor`, wiec * wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec
* token i zmiana na zywo nie moga sie rozjechac. * token i zmiana na zywo nie moga sie rozjechac.
* *
* UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie * UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie
* zrodla", czyli takze kamere i ekran. Dlatego lista jest zawsze jawna, * 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() var builder = LivekitModels.ParticipantPermission.newBuilder()
.setCanSubscribe(true) .setCanSubscribe(true)
.setCanPublishData(true) .setCanPublishData(true)
.setCanPublish(role != RoomRole.VIEWER); .setCanPublish(rights.any());
for (String source : sourcesFor(role)) { for (String source : sourcesFor(rights)) {
builder.addCanPublishSources(trackSource(source)); builder.addCanPublishSources(trackSource(source));
} }
return builder.build(); 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<String> sourcesFor(PublishRights rights) {
List<String> 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<String> sourcesFor(RoomRole role) { static List<String> sourcesFor(RoomRole role) {
return switch (role) { return sourcesFor(PublishRights.of(role));
case VIEWER -> List.of();
case SPEAKER -> AUDIO_ONLY;
case ACTIVE_PARTICIPANT -> CAMERA_AND_MIC;
case PRESENTER -> PRESENTER_SOURCES;
};
} }
static LivekitModels.TrackSource trackSource(String name) { static LivekitModels.TrackSource trackSource(String name) {
@@ -91,6 +91,8 @@ public class PublicJoinController {
*/ */
boolean canPublishVideo, boolean canPublishVideo,
boolean canPublishAudio, boolean canPublishAudio,
/** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */
boolean canShareScreen,
/** /**
* Publiczny klucz klienta do osadzenia (Zoom SDK Key). * Publiczny klucz klienta do osadzenia (Zoom SDK Key).
* Pusty dla dostawcow, ktorzy go nie potrzebuja. * Pusty dla dostawcow, ktorzy go nie potrzebuja.
@@ -111,8 +113,9 @@ public class PublicJoinController {
result.timezone(), result.timezone(),
result.provider(), result.provider(),
result.roomId(), result.roomId(),
result.ticket().role().canPublishVideo(), result.rights().camera(),
result.ticket().role().canPublishAudio(), result.rights().audio(),
result.rights().screen(),
result.ticket().clientKey()); result.ticket().clientKey());
} }
} }
@@ -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;
}
}
@@ -87,9 +87,9 @@ public class RoomPermissionSync {
// nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu // nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu
return; return;
} }
RoomRole role = videoService.participantRoles(webinarId, List.of(registrationId)) PublishRights rights = videoService.participantRights(webinarId, List.of(registrationId))
.getOrDefault(registrationId, RoomRole.VIEWER); .getOrDefault(registrationId, PublishRights.NONE);
apply(client, room, response.body(), role); apply(client, room, response.body(), rights);
} catch (Exception e) { } catch (Exception e) {
log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}", log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}",
registrationId, webinarId, e.getMessage()); registrationId, webinarId, e.getMessage());
@@ -121,7 +121,7 @@ public class RoomPermissionSync {
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-")) .map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
.filter(Objects::nonNull) .filter(Objects::nonNull)
.toList(); .toList();
Map<Long, RoomRole> roles = videoService.participantRoles(webinarId, ids); Map<Long, PublishRights> rights = videoService.participantRights(webinarId, ids);
int changed = 0; int changed = 0;
for (LivekitModels.ParticipantInfo info : participants) { for (LivekitModels.ParticipantInfo info : participants) {
@@ -129,7 +129,7 @@ public class RoomPermissionSync {
// prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia // prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia
if (id == null) continue; if (id == null) continue;
try { 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) { } catch (Exception e) {
log.warn("Uprawnienia: {} w webinarze {} nieudane: {}", log.warn("Uprawnienia: {} w webinarze {} nieudane: {}",
info.getIdentity(), webinarId, e.getMessage()); info.getIdentity(), webinarId, e.getMessage());
@@ -147,8 +147,8 @@ public class RoomPermissionSync {
* @return czy cokolwiek zostalo zmienione * @return czy cokolwiek zostalo zmienione
*/ */
private boolean apply(RoomServiceClient client, String room, private boolean apply(RoomServiceClient client, String room,
LivekitModels.ParticipantInfo info, RoomRole role) throws java.io.IOException { LivekitModels.ParticipantInfo info, PublishRights rights) throws java.io.IOException {
LivekitModels.ParticipantPermission target = liveKit.permissionFor(role); LivekitModels.ParticipantPermission target = liveKit.permissionFor(rights);
if (samePermission(info.getPermission(), target)) { if (samePermission(info.getPermission(), target)) {
return false; return false;
} }
@@ -30,13 +30,19 @@ public class RoomPresenceController {
private final RoomPresenceService presence; private final RoomPresenceService presence;
private final InteractionService interactions; private final InteractionService interactions;
private final InteractionBroadcaster broadcaster; private final InteractionBroadcaster broadcaster;
private final VideoService videoService;
private final RoomPermissionSync permissionSync;
public RoomPresenceController(RoomPresenceService presence, public RoomPresenceController(RoomPresenceService presence,
InteractionService interactions, InteractionService interactions,
InteractionBroadcaster broadcaster) { InteractionBroadcaster broadcaster,
VideoService videoService,
RoomPermissionSync permissionSync) {
this.presence = presence; this.presence = presence;
this.interactions = interactions; this.interactions = interactions;
this.broadcaster = broadcaster; this.broadcaster = broadcaster;
this.videoService = videoService;
this.permissionSync = permissionSync;
} }
@GetMapping @GetMapping
@@ -89,6 +95,32 @@ public class RoomPresenceController {
public record RemoveRequest(@NotBlank String identity) { 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 * @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
* dostawcy bez tej operacji albo gdy osoba juz wyszla * dostawcy bez tej operacji albo gdy osoba juz wyszla
@@ -272,15 +272,18 @@ public class RoomPresenceService {
if (registration != null) { if (registration != null) {
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT, result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen, registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
registration.isChatMuted(), registration.isRoomBanned())); registration.isChatMuted(), registration.isRoomBanned(),
registration.isCameraAllowed(), registration.isScreenAllowed()));
} else if (speaker != null) { } else if (speaker != null) {
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER, 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 { } else {
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie // polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
String name = info.getName().isBlank() ? identity : info.getName(); String name = info.getName().isBlank() ? identity : info.getName();
result.add(new ParticipantView(identity, name, KIND_OTHER, 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; return result;
@@ -299,10 +302,11 @@ public class RoomPresenceService {
if (r != null) { if (r != null) {
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT, view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false, 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) { } else if (s != null) {
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER, 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 { } else {
continue; continue;
} }
@@ -374,7 +378,11 @@ public class RoomPresenceService {
public record ParticipantView(String identity, String name, String kind, Long registrationId, public record ParticipantView(String identity, String name, String kind, Long registrationId,
String email, OffsetDateTime joinedAt, String email, OffsetDateTime joinedAt,
boolean audio, boolean video, boolean screen, 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) { public record BannedView(Long registrationId, String name, String email) {
@@ -121,7 +121,12 @@ public interface VideoProvider {
} }
/** Kto wchodzi do pokoju i z jakimi uprawnieniami. */ /** 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));
}
} }
/** /**
@@ -264,14 +264,15 @@ public class VideoService {
* bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie * bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
* spotkania przenosi na polaczonych `RoomPermissionSync`. * spotkania przenosi na polaczonych `RoomPermissionSync`.
*/ */
RoomRole role = participantRole(webinar, registration); PublishRights rights = participantRights(webinar, registration);
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar, 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, return new JoinResult(ticket, webinar.getTitle(), displayName, window,
webinar.getStartsAt(), webinar.getTimezone(), 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 * Identyfikatory pochodza z pokoju u dostawcy, wiec zapis z innego
* webinaru jest pomijany, a nie podnoszony. * webinaru jest pomijany, a nie podnoszony.
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Map<Long, RoomRole> participantRoles(Long webinarId, java.util.Collection<Long> registrationIds) { public Map<Long, PublishRights> participantRights(Long webinarId, java.util.Collection<Long> registrationIds) {
Webinar webinar = webinarRepository.findById(webinarId) Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru")); .orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
Map<Long, RoomRole> roles = new java.util.HashMap<>(); Map<Long, PublishRights> rights = new java.util.HashMap<>();
for (Registration registration : registrationRepository.findAllById(registrationIds)) { for (Registration registration : registrationRepository.findAllById(registrationIds)) {
if (registration.getWebinar().getId().equals(webinarId) if (registration.getWebinar().getId().equals(webinarId)
&& registration.getStatus() == RegistrationStatus.CONFIRMED && registration.getStatus() == RegistrationStatus.CONFIRMED
&& !registration.isRoomBanned()) { && !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), return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar),
webinar.getStartsAt(), webinar.getTimezone(), webinar.getStartsAt(), webinar.getTimezone(),
provider.name(), webinar.getRoomId()); provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER));
} }
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */ /** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
@@ -382,7 +422,9 @@ public class VideoService {
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */ /** Nazwa dostawcy: front wie, ktory komponent osadzic. */
String provider, String provider,
/** Identyfikator pokoju u dostawcy. */ /** Identyfikator pokoju u dostawcy. */
String roomId) { String roomId,
/** Co wolno nadawac: mikrofon, kamera, ekran. */
PublishRights rights) {
} }
/** /**
@@ -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;
@@ -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<TrackSource> 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);
}
}
@@ -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<String, Object> 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<String, Object>) claims.get("video");
}
@SuppressWarnings("unchecked")
private static List<String> sources(Map<String, Object> video) {
Object raw = video.get("canPublishSources");
return raw == null ? List.of() : (List<String>) 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);
}
}
+10 -3
View File
@@ -81,10 +81,16 @@ type History = {
questions: InteractionView[] 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 = { export type ModerationEvent = {
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
registrationId: number registrationId: number
/** Tylko przy PUBLISH_CHANGED. */
camera?: boolean
screen?: boolean
} }
type Event = { kind: string; payload: InteractionView } type Event = { kind: string; payload: InteractionView }
@@ -245,7 +251,8 @@ export function useInteractions(
} }
if ( if (
event.kind === 'MUTED' || event.kind === 'UNMUTED' || 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 const mod = event as unknown as ModerationEvent
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest // reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
+23
View File
@@ -75,6 +75,8 @@ type JoinView = {
*/ */
canPublishVideo: boolean canPublishVideo: boolean
canPublishAudio: boolean canPublishAudio: boolean
/** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
canShareScreen: boolean
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */ /** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
clientKey: string | null clientKey: string | null
} }
@@ -369,6 +371,24 @@ function MeetingRoom({
</p> </p>
)} )}
{/*
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 && (
<p className="border-b border-info-500/20 bg-info-50 px-3 py-2 text-xs text-info-600">
Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem.
</p>
)}
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canShareScreen && (
<p className="border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
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.
</p>
)}
<InteractionPanel <InteractionPanel
token={token} token={token}
onSpeakEvent={speak.apply} onSpeakEvent={speak.apply}
@@ -377,6 +397,9 @@ function MeetingRoom({
onModeration={(event) => { onModeration={(event) => {
if (event.kind === 'BANNED') setBanned(true) if (event.kind === 'BANNED') setBanned(true)
if (event.kind === 'UNBANNED') setBanned(false) 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) => { onHistoryLoaded={(history) => {
setOpenMic(history.openMic) setOpenMic(history.openMic)
@@ -21,6 +21,10 @@ type Participant = {
screen: boolean screen: boolean
chatMuted: boolean chatMuted: boolean
roomBanned: boolean roomBanned: boolean
/** Indywidualna zgoda prowadzacego na kamere. */
cameraAllowed: boolean
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
screenAllowed: boolean
} }
type Presence = { 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) { async function unban(registrationId: number, name: string) {
setBusy(`ban-${registrationId}`) setBusy(`ban-${registrationId}`)
try { try {
@@ -263,6 +299,53 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
{participant.kind !== 'SPEAKER' && ( {participant.kind !== 'SPEAKER' && (
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">
{/*
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' && (
<>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'camera')}
aria-pressed={participant.cameraAllowed}
aria-label={
participant.cameraAllowed
? `Odbierz kamerę: ${participant.name}`
: `Pozwól włączyć kamerę: ${participant.name}`
}
title={participant.cameraAllowed ? 'Odbierz kamerę' : 'Pozwól włączyć kamerę'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.cameraAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={participant.cameraAllowed ? 'video' : 'video-off'} size={16} />
</button>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'screen')}
aria-pressed={participant.screenAllowed}
aria-label={
participant.screenAllowed
? `Odbierz udostępnianie ekranu: ${participant.name}`
: `Pozwól udostępnić ekran: ${participant.name}`
}
title={participant.screenAllowed ? 'Odbierz udostępnianie ekranu' : 'Pozwól udostępnić ekran'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.screenAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name="presentation" size={16} />
</button>
</>
)}
{participant.kind === 'PARTICIPANT' && ( {participant.kind === 'PARTICIPANT' && (
<button <button
type="button" type="button"