diff --git a/CLAUDE.md b/CLAUDE.md index 01c101d..9475ff2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1649,12 +1649,24 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket - **Osobna tabela, nie kolejny typ w `interactions`.** Zgloszenie nie jest wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum. Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma. -- **Glos wynika z tokenu, nie ze zgloszenia.** Przyznanie ustawia - `registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero token - wydany u dostawcy. Gdyby wystarczal stan w przegladarce, podmiana odpowiedzi - pozwalalaby mowic bez zgody moderatora. **Po zmianie roli front pobiera - token na nowo**: bez tego uczestnik widzi napis "masz glos" i ma zamkniety - mikrofon. +- **Glos wynika z uprawnien u dostawcy, nie ze zgloszenia.** Przyznanie ustawia + `registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero uprawnienie + nadane przez serwer pokoju. Gdyby wystarczal stan w przegladarce, podmiana + odpowiedzi pozwalalaby mowic bez zgody moderatora. +- **Przy LiveKit uprawnienia zmienia na zywo SERWER** (`RoomPermissionSync`, + `updateParticipant`), bez ponownego wejscia na sale. Wczesniej zmiana szla + wylacznie przez nowy token i przemontowanie sali w przegladarce: odebranie + glosu dzialalo tylko u klienta, ktory posluchal zdarzenia, a kazda zmiana + gasila obraz i dzwiek na chwile. Odebranie najpierw wycisza sciezke + (`mutePublishedTrack`), potem zmienia uprawnienia. +- **Sala LiveKit ma staly `key`.** Nowy token nadal jest pobierany, ale na + polaczonej sali podmiana nic nie robi; sluzy ponownemu wejsciu po zerwaniu. +- **Mikrofon wlacza sie sam po przyznaniu glosu** (`MicOnGrant`), raz na + przyznanie i dopiero po nadaniu uprawnienia przez serwer. Otwarty mikrofon + dla calej sali tego nie robi, bo tam nikt o glos nie prosil. +- **`canPublish=true` z pusta lista zrodel znaczy u LiveKit "wszystkie + zrodla".** Kazda rola publikujaca ma jawna liste; pilnuje tego + `RoomPermissionTest`, sprawdzony w obie strony. - **Zgloszenie i rola zmieniaja sie w jednej transakcji.** Rozdzielenie daje uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty, prowadzacy nie wie dlaczego) albo odwrotnie. @@ -1717,8 +1729,10 @@ Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia. nieaktywna, a nie nieaktualna. - **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to `canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery. -- Przelaczenie idzie tematem `speak`, zeby otwarte sale pobraly token na nowo: - bez tego uprawnienie zmienia sie w bazie, a przegladarki dzialaja na starym. +- Przelaczenie zmienia uprawnienia calej sali po stronie serwera + (`RoomPermissionSync.syncWebinar`, w tle, z pominieciem osob bez zmiany) + i idzie tematem `speak`, zeby przegladarki pobraly aktualny token na wypadek + ponownego wejscia. ### Sygnal dzwiekowy w panelu prowadzenia diff --git a/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java b/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java index d9e7ede..3cf337e 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/PublicTaskOfferController.java @@ -47,6 +47,7 @@ public class PublicTaskOfferController { private final InteractionService interactions; private final SalesNotifier salesNotifier; private final RegistrationRepository registrationRepository; + private final com.softspm.webinar.video.RoomPermissionSync permissionSync; public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners, SpeakRequestService speakRequests, @@ -55,7 +56,9 @@ public class PublicTaskOfferController { InteractionBroadcaster broadcaster, InteractionService interactions, SalesNotifier salesNotifier, - RegistrationRepository registrationRepository) { + RegistrationRepository registrationRepository, + com.softspm.webinar.video.RoomPermissionSync permissionSync) { + this.permissionSync = permissionSync; this.banners = banners; this.speakRequests = speakRequests; this.tasks = tasks; @@ -217,7 +220,10 @@ public class PublicTaskOfferController { @Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu") public SpeakStateView withdrawSpeak(@PathVariable String token) { TaskService.ParticipantDetails participant = participant(token); - return new SpeakStateView(speakRequests.withdraw(participant.registrationId())); + SpeakRequestView view = speakRequests.withdraw(participant.registrationId()); + // rezygnacja z przyznanego glosu zamyka mikrofon po stronie serwera + permissionSync.syncParticipant(participant.webinarId(), participant.registrationId()); + return new SpeakStateView(view); } private TaskService.ParticipantDetails participant(String token) { diff --git a/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java b/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java index 3d612f6..558f3bb 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/SpeakRequestAdminController.java @@ -1,5 +1,6 @@ package com.softspm.webinar.interaction; +import com.softspm.webinar.video.RoomPermissionSync; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.DeleteMapping; @@ -30,9 +31,12 @@ import java.util.List; public class SpeakRequestAdminController { private final SpeakRequestService speakRequests; + private final RoomPermissionSync permissionSync; - public SpeakRequestAdminController(SpeakRequestService speakRequests) { + public SpeakRequestAdminController(SpeakRequestService speakRequests, + RoomPermissionSync permissionSync) { this.speakRequests = speakRequests; + this.permissionSync = permissionSync; } @GetMapping @@ -44,7 +48,11 @@ public class SpeakRequestAdminController { @PostMapping("/{requestId}/grant") @Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.") public SpeakRequestView grant(@PathVariable Long id, @PathVariable Long requestId) { - return speakRequests.grant(id, requestId); + SpeakRequestView view = speakRequests.grant(id, requestId); + // po zatwierdzeniu transakcji: serwer pokoju otwiera mikrofon od razu, + // bez ponownego wejscia uczestnika na sale + permissionSync.syncParticipant(id, view.registrationId()); + return view; } /** @@ -59,20 +67,27 @@ public class SpeakRequestAdminController { @Operation(summary = "Wlaczenie albo wylaczenie trybu otwartego mikrofonu") public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id, @RequestBody OpenMicRequest request) { - return speakRequests.setOpenMic(id, request.openMic()); + var result = speakRequests.setOpenMic(id, request.openMic()); + permissionSync.syncWebinar(id); + return result; } @PutMapping("/open-video") @Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich") public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id, @RequestBody OpenVideoRequest request) { - return speakRequests.setOpenVideo(id, request.openVideo()); + var result = speakRequests.setOpenVideo(id, request.openVideo()); + permissionSync.syncWebinar(id); + return result; } @DeleteMapping("/{requestId}") @Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.") public SpeakRequestView revoke(@PathVariable Long id, @PathVariable Long requestId) { - return speakRequests.revoke(id, requestId); + SpeakRequestView view = speakRequests.revoke(id, requestId); + // serwer pokoju zamyka mikrofon takze klientowi, ktory nie posluchal zdarzenia + permissionSync.syncParticipant(id, view.registrationId()); + return view; } public record OpenMicRequest(boolean openMic) { 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 08fd70a..b8f436e 100644 --- a/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java +++ b/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java @@ -13,6 +13,7 @@ import io.livekit.server.RoomAdmin; import io.livekit.server.RoomJoin; import io.livekit.server.RoomName; import io.livekit.server.RoomRecord; +import livekit.LivekitModels; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -175,6 +176,49 @@ public class LiveKitVideoProvider implements VideoProvider { }; } + /** + * 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 + * 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`. + */ + LivekitModels.ParticipantPermission permissionFor(RoomRole role) { + var builder = LivekitModels.ParticipantPermission.newBuilder() + .setCanSubscribe(true) + .setCanPublishData(true) + .setCanPublish(role != RoomRole.VIEWER); + for (String source : sourcesFor(role)) { + builder.addCanPublishSources(trackSource(source)); + } + return builder.build(); + } + + /** Zrodla, ktore rola moze publikowac. Pusta lista przy VIEWER. */ + 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; + }; + } + + static LivekitModels.TrackSource trackSource(String name) { + return switch (name) { + case "camera" -> LivekitModels.TrackSource.CAMERA; + case "microphone" -> LivekitModels.TrackSource.MICROPHONE; + case "screen_share" -> LivekitModels.TrackSource.SCREEN_SHARE; + case "screen_share_audio" -> LivekitModels.TrackSource.SCREEN_SHARE_AUDIO; + default -> throw new IllegalArgumentException("Nieznane zrodlo: " + name); + }; + } + /** * Nagrania. * diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java b/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java new file mode 100644 index 0000000..6a25ade --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java @@ -0,0 +1,186 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.repository.WebinarRepository; +import io.livekit.server.RoomServiceClient; +import livekit.LivekitModels; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Zmiana uprawnien uczestnika juz polaczonego z sala. + * + * DLACZEGO NIE WYSTARCZY NOWY TOKEN + * --------------------------------------------------------------------------- + * Wczesniej przyznanie i odebranie glosu zmienialo wylacznie role w bazie, + * a przegladarka uczestnika po zdarzeniu pobierala nowy token i wchodzila na + * sale od nowa. Skutki widac bylo na zywo: + * - odebranie glosu dzialalo tylko wtedy, gdy przegladarka posluchala; klient, + * do ktorego zdarzenie nie doszlo, mowil dalej ze starym tokenem, + * - kazda zmiana oznaczala ponowne wejscie: chwila ciemnego ekranu, zerwany + * dzwiek, migajacy licznik osob na sali. + * + * Teraz uprawnienia zmienia SERWER POKOJU (`updateParticipant`), a biblioteka + * klienta dostaje je na zywo i sama pokazuje albo chowa przycisk mikrofonu. + * Nowy token nadal jest pobierany, ale sluzy wylacznie ponownemu wejsciu po + * zerwaniu polaczenia. + * + * ODEBRANIE NAJPIERW WYCISZA + * --------------------------------------------------------------------------- + * Sciezka, ktorej rola juz nie obejmuje, jest wyciszana po stronie serwera + * (`mutePublishedTrack`) PRZED zmiana uprawnien. Serwer zdejmuje takie sciezki + * takze sam przy zmianie uprawnien, ale wyciszenie jest natychmiastowe + * i nie zalezy od tego, jak wersja serwera obsluguje odebrane zrodla. + * + * WYLACZNIE LIVEKIT + * --------------------------------------------------------------------------- + * Przy pozostalych dostawcach nie ma takiej operacji i zostaje dotychczasowa + * droga przez nowy token. Kazdy blad jest tu logowany i POMIJANY: zmiana roli + * jest juz zapisana w bazie, a nieudana synchronizacja nie moze cofnac + * decyzji prowadzacego ani wywrocic jego zadania. + */ +@Service +public class RoomPermissionSync { + + private static final Logger log = LoggerFactory.getLogger(RoomPermissionSync.class); + + private final WebinarRepository webinarRepository; + private final VideoService videoService; + private final RoomPresenceService presence; + private final LiveKitVideoProvider liveKit; + + public RoomPermissionSync(WebinarRepository webinarRepository, + VideoService videoService, + RoomPresenceService presence, + LiveKitVideoProvider liveKit) { + this.webinarRepository = webinarRepository; + this.videoService = videoService; + this.presence = presence; + this.liveKit = liveKit; + } + + /** + * Jeden uczestnik: po przyznaniu, odebraniu albo wycofaniu glosu. + * + * Synchronicznie, bo to jedno zadanie do serwera pokoju, a prowadzacy ma + * po kliknieciu widziec skutek, nie czekac na watek w tle. + */ + public void syncParticipant(Long webinarId, Long registrationId) { + try { + Webinar webinar = liveKitWebinar(webinarId); + if (webinar == null) return; + + String room = liveKit.roomIdOf(webinar); + String identity = "reg-" + registrationId; + RoomServiceClient client = presence.roomClient(); + + var response = client.getParticipant(room, identity).execute(); + if (!response.isSuccessful() || response.body() == null) { + // 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); + } catch (Exception e) { + log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}", + registrationId, webinarId, e.getMessage()); + } + } + + /** + * Cala sala: po przelaczeniu otwartego mikrofonu albo wideo dla wszystkich. + * + * W tle, bo przy dwustu osobach to dwiescie zadan do serwera pokoju, + * a prowadzacy nie moze czekac na nie z zablokowanym przelacznikiem. + * Osoby, ktorych uprawnienia sie nie zmieniaja, sa pomijane bez zadania. + */ + @Async + public void syncWebinar(Long webinarId) { + try { + Webinar webinar = liveKitWebinar(webinarId); + if (webinar == null) return; + + String room = liveKit.roomIdOf(webinar); + RoomServiceClient client = presence.roomClient(); + var response = client.listParticipants(room).execute(); + if (!response.isSuccessful() || response.body() == null) { + return; + } + + List participants = response.body(); + List ids = participants.stream() + .map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-")) + .filter(Objects::nonNull) + .toList(); + Map roles = videoService.participantRoles(webinarId, ids); + + int changed = 0; + for (LivekitModels.ParticipantInfo info : participants) { + Long id = RoomPresenceService.idOf(info.getIdentity(), "reg-"); + // prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia + if (id == null) continue; + try { + if (apply(client, room, info, roles.getOrDefault(id, RoomRole.VIEWER))) changed++; + } catch (Exception e) { + log.warn("Uprawnienia: {} w webinarze {} nieudane: {}", + info.getIdentity(), webinarId, e.getMessage()); + } + } + log.info("Uprawnienia: webinar {}, zmieniono {} z {} uczestnikow", webinarId, changed, ids.size()); + } catch (Exception e) { + log.warn("Uprawnienia: synchronizacja webinaru {} nieudana: {}", webinarId, e.getMessage()); + } + } + + /** + * Nadaje uczestnikowi uprawnienia roli. + * + * @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); + if (samePermission(info.getPermission(), target)) { + return false; + } + + Set allowed = new HashSet<>(target.getCanPublishSourcesList()); + for (LivekitModels.TrackInfo track : info.getTracksList()) { + boolean permitted = target.getCanPublish() && allowed.contains(track.getSource()); + if (!permitted && !track.getMuted()) { + client.mutePublishedTrack(room, info.getIdentity(), track.getSid(), true).execute(); + } + } + + var response = client.updateParticipant(room, info.getIdentity(), null, null, target, null).execute(); + if (!response.isSuccessful()) { + log.warn("Uprawnienia: serwer odrzucil zmiane {} (kod {})", info.getIdentity(), response.code()); + return false; + } + return true; + } + + private static boolean samePermission(LivekitModels.ParticipantPermission current, + LivekitModels.ParticipantPermission target) { + return current.getCanPublish() == target.getCanPublish() + && current.getCanSubscribe() == target.getCanSubscribe() + && current.getCanPublishData() == target.getCanPublishData() + && new HashSet<>(current.getCanPublishSourcesList()) + .equals(new HashSet<>(target.getCanPublishSourcesList())); + } + + /** Webinar, o ile jego pokoj stoi u LiveKit; inaczej null. */ + private Webinar liveKitWebinar(Long webinarId) { + Webinar webinar = webinarRepository.findById(webinarId).orElse(null); + return webinar != null && presence.usesLiveKit(webinar) ? webinar : null; + } +} 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 c059469..6eb124f 100644 --- a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java @@ -205,13 +205,13 @@ public class RoomPresenceService { * ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje * wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu. */ - private boolean usesLiveKit(Webinar webinar) { + boolean usesLiveKit(Webinar webinar) { VideoProvider existing = providerFactory.ofExistingRoom(webinar); VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar); return LiveKitVideoProvider.NAME.equals(provider.name()); } - private RoomServiceClient roomClient() { + RoomServiceClient roomClient() { return RoomServiceClient.createClient( egressService.apiHttpUrl(), settings.get(SettingKey.LIVEKIT_API_KEY), 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 51aee4a..9f2884b 100644 --- a/backend/src/main/java/com/softspm/webinar/video/VideoService.java +++ b/backend/src/main/java/com/softspm/webinar/video/VideoService.java @@ -259,32 +259,12 @@ public class VideoService { String displayName = (registration.getFirstName() + " " + registration.getLastName()).trim(); /* - * Uprawnienia do mowienia. - * - * Przy otwartym mikrofonie kazdy uczestnik dostaje SPEAKER, bez - * proszenia o glos. Decyzja musi zapasc **tutaj**, przy wydawaniu + * Uprawnienia do mowienia i obrazu. Decyzja zapada przy wydawaniu * tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje, - * bo dostawca i tak odrzuci publikacje bez uprawnienia w tokenie. - * - * Wideo zostaje wylaczone takze w tym trybie: SPEAKER u dostawcy - * oznacza u nas prawo do dzwieku, nie do obrazu. + * bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie + * spotkania przenosi na polaczonych `RoomPermissionSync`. */ - /* - * Widocznosc wideo ma pierwszenstwo nad otwartym mikrofonem: tryb - * "wideo dla wszystkich" daje uczestnikowi obraz I dzwiek. Nastepny - * jest otwarty mikrofon (sam dzwiek). Na koncu rola z zapisu, ktora - * uwzglednia indywidualnie przyznany glos. Uprawnienie zawsze wynika - * z tokenu, wiec przelacznik po stronie prowadzacego nie da uczestnikowi - * niczego, dopoki nie pobierze tokenu na nowo. - */ - RoomRole role; - if (webinar.isOpenVideo()) { - role = RoomRole.ACTIVE_PARTICIPANT; - } else if (webinar.isOpenMic()) { - role = RoomRole.SPEAKER; - } else { - role = registration.getRoomRole().toRoomRole(); - } + RoomRole role = participantRole(webinar, registration); VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar, new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(), role)); @@ -294,6 +274,51 @@ public class VideoService { provider.name(), webinar.getRoomId()); } + /** + * Rola uczestnika z zapisu na sali. + * + * Widocznosc wideo ma pierwszenstwo nad otwartym mikrofonem: tryb "wideo + * dla wszystkich" daje obraz I dzwiek. Nastepny jest otwarty mikrofon (sam + * dzwiek). Na koncu rola z zapisu, ktora uwzglednia indywidualnie przyznany + * glos. + * + * JEDNO MIEJSCE DLA BILETU I DLA ZMIANY NA ZYWO. Rozstrzyga tu zarowno + * token wydawany przy wejsciu, jak i uprawnienia zmieniane w trakcie przez + * `RoomPermissionSync`. Dwie kopie tej reguly rozjechalyby sie przy + * pierwszej poprawce, a uczestnik mialby inne prawa po ponownym wejsciu + * niz przed nim. + */ + public static RoomRole participantRole(Webinar webinar, Registration registration) { + if (webinar.isOpenVideo()) { + return RoomRole.ACTIVE_PARTICIPANT; + } + if (webinar.isOpenMic()) { + return RoomRole.SPEAKER; + } + return registration.getRoomRole().toRoomRole(); + } + + /** + * Role 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) { + Webinar webinar = webinarRepository.findById(webinarId) + .orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru")); + Map roles = 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)); + } + } + return roles; + } + /** * Wejscie prelegenta. * diff --git a/backend/src/test/java/com/softspm/webinar/video/RoomPermissionTest.java b/backend/src/test/java/com/softspm/webinar/video/RoomPermissionTest.java new file mode 100644 index 0000000..8c78748 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/video/RoomPermissionTest.java @@ -0,0 +1,56 @@ +package com.softspm.webinar.video; + +import livekit.LivekitModels.TrackSource; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Uprawnienia zmieniane na zywo (`RoomPermissionSync`) musza odpowiadac tym + * z tokenu. + * + * Najgrozniejsza pulapka: u LiveKit `canPublish=true` z PUSTA lista zrodel + * znaczy "wszystkie zrodla". Uczestnik z glosem dostalby wtedy kamere + * i udostepnianie ekranu, a blad nie bylby widoczny w interfejsie, dopoki + * ktos nie sprobuje ich wlaczyc. + */ +class RoomPermissionTest { + + private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null); + + @Test + void viewerCannotPublishAnything() { + var permission = provider.permissionFor(RoomRole.VIEWER); + assertThat(permission.getCanPublish()).isFalse(); + assertThat(permission.getCanSubscribe()).isTrue(); + } + + @Test + void speakerGetsMicrophoneOnly() { + var permission = provider.permissionFor(RoomRole.SPEAKER); + assertThat(permission.getCanPublish()).isTrue(); + assertThat(Set.copyOf(permission.getCanPublishSourcesList())) + .containsExactly(TrackSource.MICROPHONE); + } + + @Test + void activeParticipantGetsCameraAndMicrophoneButNoScreen() { + var permission = provider.permissionFor(RoomRole.ACTIVE_PARTICIPANT); + assertThat(Set.copyOf(permission.getCanPublishSourcesList())) + .containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE); + } + + @Test + void everyPublishingRoleHasExplicitSources() { + for (RoomRole role : RoomRole.values()) { + var permission = provider.permissionFor(role); + if (permission.getCanPublish()) { + assertThat(permission.getCanPublishSourcesList()) + .as("rola %s: pusta lista zrodel oznacza u LiveKit wszystkie zrodla", role) + .isNotEmpty(); + } + } + } +} diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 6ae08c2..b988f5f 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -37,6 +37,7 @@ import { MailCheck, Menu, MessageSquare, + MessageSquareOff, Pencil, Plus, Play, @@ -106,6 +107,7 @@ const ICONS = { 'mail-check': MailCheck, menu: Menu, 'message-square': MessageSquare, + 'message-square-off': MessageSquareOff, pencil: Pencil, plus: Plus, play: Play, diff --git a/frontend/src/pages/public/LiveKitRoom.tsx b/frontend/src/pages/public/LiveKitRoom.tsx index 4cd534e..c7e7306 100644 --- a/frontend/src/pages/public/LiveKitRoom.tsx +++ b/frontend/src/pages/public/LiveKitRoom.tsx @@ -3,9 +3,10 @@ import { LiveKitRoom as LiveKitRoomProvider, RoomAudioRenderer, useLocalParticipant, + useLocalParticipantPermissions, } from '@livekit/components-react' import { RoomOptions } from 'livekit-client' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { BackgroundBlurToggle } from './BackgroundBlurToggle' import { WebinarStage } from './WebinarStage' @@ -43,6 +44,7 @@ export function LiveKitRoom({ initialAudio = false, initialVideo = false, onDevicesChange, + speakGranted = false, }: { serverUrl: string token: string @@ -59,6 +61,12 @@ export function LiveKitRoom({ initialVideo?: boolean /** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */ onDevicesChange?: (state: { audio: boolean; video: boolean }) => void + /** + * Prowadzacy przyznal glos na prosbe uczestnika. Mikrofon wlacza sie wtedy + * sam, gdy serwer nada prawo publikacji. Otwarty mikrofon dla calej sali tego + * nie robi: tam nikt o glos nie prosil. + */ + speakGranted?: boolean }) { const options = useMemo( () => ({ @@ -103,6 +111,7 @@ export function LiveKitRoom({ {/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */} + {/* Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad @@ -139,3 +148,34 @@ function DeviceStateReporter({ return null } + +/** + * Wlacza mikrofon po przyznaniu glosu. + * + * Uczestnik prosil o glos, wiec po zgodzie ma od razu mowic, a nie szukac + * przycisku. Czeka na UPRAWNIENIE od serwera, a nie na samo zdarzenie + * o przyznaniu: to drugie potrafi przyjsc szybciej, a proba publikacji przed + * nadaniem prawa konczy sie odmowa. + * + * Raz na przyznanie: jesli uczestnik sam sie wyciszy, nie wlaczamy mu + * mikrofonu z powrotem przy kolejnej zmianie uprawnien. + */ +function MicOnGrant({ granted }: { granted: boolean }) { + const { localParticipant } = useLocalParticipant() + const permissions = useLocalParticipantPermissions() + const done = useRef(false) + + useEffect(() => { + if (!granted) { + done.current = false + return + } + if (done.current || !permissions?.canPublish) return + done.current = true + localParticipant.setMicrophoneEnabled(true).catch(() => { + // brak zgody przegladarki na mikrofon: uczestnik wlaczy go przyciskiem + }) + }, [granted, permissions, localParticipant]) + + return null +} diff --git a/frontend/src/pages/public/MeetingActionBar.tsx b/frontend/src/pages/public/MeetingActionBar.tsx index 2a6814a..e21728a 100644 --- a/frontend/src/pages/public/MeetingActionBar.tsx +++ b/frontend/src/pages/public/MeetingActionBar.tsx @@ -217,6 +217,8 @@ function SpeakButton({ /* * Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia: * mowi o tym, co uczestnik faktycznie moze, a nie o tym, o co prosil. + * Nie twierdzi, ze mikrofon jest wlaczony: sala wlacza go sama po + * przyznaniu glosu, ale uczestnik moze go zaraz wyciszyc przyciskiem. * Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego. */ if (speak.granted) { @@ -224,7 +226,7 @@ function SpeakButton({
- {canPublishAudio ? 'Masz głos, mikrofon jest włączony' : 'Masz głos, trwa włączanie mikrofonu...'} + {canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
{participant.email && {participant.email}} @@ -271,12 +271,12 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t aria-label={ participant.chatMuted ? `Przywróć pisanie na czacie: ${participant.name}` - : `Wycisz na czacie: ${participant.name}` + : `Zablokuj pisanie na czacie: ${participant.name}` } - title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Wycisz na czacie'} + title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'} className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40" > - + )}