Glos na zywo po stronie serwera, ikona blokady czatu
Deploy PROD / deploy (push) Successful in 26s

Przyznanie i odebranie glosu oraz oba tryby (otwarty mikrofon, wideo dla
wszystkich) zmieniaja uprawnienia uczestnika przez updateParticipant,
bez ponownego wejscia na sale. Odebranie najpierw wycisza sciezke na
serwerze, wiec dziala takze u klienta, ktory nie odebral zdarzenia.
Sala LiveKit ma staly key; mikrofon wlacza sie sam po przyznaniu glosu.

Rola uczestnika liczona w jednym miejscu (VideoService.participantRole)
dla biletu i zmiany na zywo. RoomPermissionTest pilnuje jawnej listy
zrodel publikacji.

Blokada pisania na czacie ma ikone przekreslonego dymku i znacznik
"bez czatu" zamiast przekreslonego mikrofonu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:55:24 +02:00
parent e9bb4af59f
commit 515d9a41e2
14 changed files with 465 additions and 75 deletions
@@ -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) {
@@ -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) {
@@ -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<String> 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.
*
@@ -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<LivekitModels.ParticipantInfo> participants = response.body();
List<Long> ids = participants.stream()
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
.filter(Objects::nonNull)
.toList();
Map<Long, RoomRole> 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<LivekitModels.TrackSource> 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;
}
}
@@ -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),
@@ -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<Long, RoomRole> participantRoles(Long webinarId, java.util.Collection<Long> registrationIds) {
Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
Map<Long, RoomRole> 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.
*
@@ -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();
}
}
}
}