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:
@@ -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
|
||||
|
||||
|
||||
+8
-2
@@ -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) {
|
||||
|
||||
+20
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RoomOptions>(
|
||||
() => ({
|
||||
@@ -103,6 +111,7 @@ export function LiveKitRoom({
|
||||
<WebinarStage />
|
||||
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
|
||||
<DeviceStateReporter onChange={onDevicesChange} />
|
||||
<MicOnGrant granted={speakGranted} />
|
||||
{/*
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
|
||||
<Icon name="check-circle" size={16} />
|
||||
{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...'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -303,7 +303,7 @@ function MeetingRoom({
|
||||
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
|
||||
i banery zostaja nasze niezaleznie od tego wyboru.
|
||||
*/}
|
||||
<VideoArea join={join} src={src} />
|
||||
<VideoArea join={join} src={src} speakGranted={speak.granted} />
|
||||
|
||||
{/*
|
||||
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
|
||||
@@ -396,7 +396,15 @@ function MeetingRoom({
|
||||
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
|
||||
* dziala jego gotowy komponent.
|
||||
*/
|
||||
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
function VideoArea({
|
||||
join,
|
||||
src,
|
||||
speakGranted,
|
||||
}: {
|
||||
join: JoinView
|
||||
src: string | null
|
||||
speakGranted: boolean
|
||||
}) {
|
||||
/*
|
||||
* Stan urzadzen przezywajacy przemontowanie sali.
|
||||
*
|
||||
@@ -417,30 +425,21 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||
<Suspense fallback={<VideoLoading />}>
|
||||
{/*
|
||||
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
|
||||
BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
|
||||
---------------------------------------------------------------
|
||||
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
|
||||
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
|
||||
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
|
||||
mikrofonu, dopoki uczestnik nie przeladuje strony.
|
||||
Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
|
||||
po stronie backendu), a biblioteka sama pokazuje albo chowa
|
||||
przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
|
||||
przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
|
||||
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
|
||||
|
||||
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje
|
||||
z `audio={false}`, wiec kazda opublikowana sciezka przepada
|
||||
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od
|
||||
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny
|
||||
ciag (JWT niesie czas wydania), takze gdy uprawnienia sa te same.
|
||||
|
||||
Zdarzylo sie to na zywo: prowadzacy wylaczyl "wideo dla wszystkich"
|
||||
przy wlaczonym otwartym mikrofonie, front pobral token i przemontowal
|
||||
sale CALEJ grupie, wyciszajac ludzi w trakcie wypowiedzi. Prawo do
|
||||
mikrofonu wcale sie wtedy nie zmienilo.
|
||||
|
||||
Klucz sklada wiec faktyczne uprawnienia tego uczestnika, ktore
|
||||
backend wylicza z roli, obu trybow i przyznanego glosu. Zmiana
|
||||
trybu, ktora go nie dotyczy, nie rusza jego sali.
|
||||
Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
|
||||
sali podmiana nic nie robi (`room.connect` przy stanie Connected
|
||||
konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
|
||||
po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
|
||||
*/}
|
||||
<LiveKitRoom
|
||||
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
|
||||
key="sala"
|
||||
serverUrl={join.url}
|
||||
token={join.token}
|
||||
/*
|
||||
@@ -454,6 +453,7 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
onDevicesChange={(state) => {
|
||||
devicesRef.current = state
|
||||
}}
|
||||
speakGranted={speakGranted}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -341,7 +341,7 @@ export function HostConsolePage() {
|
||||
}
|
||||
toasts.success(
|
||||
kind === 'mute'
|
||||
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
|
||||
? value ? 'Zablokowano pisanie na czacie' : 'Przywrócono pisanie na czacie'
|
||||
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -499,7 +499,7 @@ function ChatColumn({
|
||||
</span>
|
||||
{message.authorChatMuted && (
|
||||
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
|
||||
wyciszony
|
||||
bez czatu
|
||||
</span>
|
||||
)}
|
||||
{message.authorRoomBanned && (
|
||||
@@ -518,11 +518,11 @@ function ChatColumn({
|
||||
onClick={() =>
|
||||
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
|
||||
}
|
||||
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
|
||||
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
|
||||
aria-label={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
|
||||
title={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
|
||||
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
|
||||
>
|
||||
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
|
||||
<Icon name={message.authorChatMuted ? 'message-square' : 'message-square-off'} size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -134,7 +134,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
||||
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
|
||||
{ value: !participant.chatMuted },
|
||||
)
|
||||
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Wyciszono na czacie')
|
||||
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Zablokowano pisanie na czacie')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
|
||||
@@ -229,7 +229,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
||||
<span className="truncate font-medium text-slate-800">{participant.name}</span>
|
||||
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
|
||||
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
|
||||
{participant.chatMuted && <Badge tone="warn">wyciszony</Badge>}
|
||||
{participant.chatMuted && <Badge tone="warn">bez czatu</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||
{participant.email && <span className="truncate">{participant.email}</span>}
|
||||
@@ -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"
|
||||
>
|
||||
<Icon name={participant.chatMuted ? 'message-square' : 'mic-off'} size={16} />
|
||||
<Icon name={participant.chatMuted ? 'message-square' : 'message-square-off'} size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user