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
|
- **Osobna tabela, nie kolejny typ w `interactions`.** Zgloszenie nie jest
|
||||||
wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum.
|
wypowiedzia: nie ma tresci, nie idzie na czat, nie trafia do archiwum.
|
||||||
Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma.
|
Ma za to wlasny cykl zycia, ktorego zadna interakcja nie ma.
|
||||||
- **Glos wynika z tokenu, nie ze zgloszenia.** Przyznanie ustawia
|
- **Glos wynika z uprawnien u dostawcy, nie ze zgloszenia.** Przyznanie ustawia
|
||||||
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero token
|
`registrations.room_role` na SPEAKER, a mikrofon otwiera dopiero uprawnienie
|
||||||
wydany u dostawcy. Gdyby wystarczal stan w przegladarce, podmiana odpowiedzi
|
nadane przez serwer pokoju. Gdyby wystarczal stan w przegladarce, podmiana
|
||||||
pozwalalaby mowic bez zgody moderatora. **Po zmianie roli front pobiera
|
odpowiedzi pozwalalaby mowic bez zgody moderatora.
|
||||||
token na nowo**: bez tego uczestnik widzi napis "masz glos" i ma zamkniety
|
- **Przy LiveKit uprawnienia zmienia na zywo SERWER** (`RoomPermissionSync`,
|
||||||
mikrofon.
|
`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
|
- **Zgloszenie i rola zmieniaja sie w jednej transakcji.** Rozdzielenie daje
|
||||||
uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty,
|
uczestnika z rola SPEAKER i bez zgloszenia w kolejce (mikrofon otwarty,
|
||||||
prowadzacy nie wie dlaczego) albo odwrotnie.
|
prowadzacy nie wie dlaczego) albo odwrotnie.
|
||||||
@@ -1717,8 +1729,10 @@ Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
|
|||||||
nieaktywna, a nie nieaktualna.
|
nieaktywna, a nie nieaktualna.
|
||||||
- **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to
|
- **Wideo zostaje wylaczone takze w tym trybie.** `RoomRole.SPEAKER` to
|
||||||
`canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery.
|
`canPublishVideo=false`, wiec otwarty mikrofon nie moze wlaczyc nikomu kamery.
|
||||||
- Przelaczenie idzie tematem `speak`, zeby otwarte sale pobraly token na nowo:
|
- Przelaczenie zmienia uprawnienia calej sali po stronie serwera
|
||||||
bez tego uprawnienie zmienia sie w bazie, a przegladarki dzialaja na starym.
|
(`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
|
### Sygnal dzwiekowy w panelu prowadzenia
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -47,6 +47,7 @@ public class PublicTaskOfferController {
|
|||||||
private final InteractionService interactions;
|
private final InteractionService interactions;
|
||||||
private final SalesNotifier salesNotifier;
|
private final SalesNotifier salesNotifier;
|
||||||
private final RegistrationRepository registrationRepository;
|
private final RegistrationRepository registrationRepository;
|
||||||
|
private final com.softspm.webinar.video.RoomPermissionSync permissionSync;
|
||||||
|
|
||||||
public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners,
|
public PublicTaskOfferController(com.softspm.webinar.banner.BannerService banners,
|
||||||
SpeakRequestService speakRequests,
|
SpeakRequestService speakRequests,
|
||||||
@@ -55,7 +56,9 @@ public class PublicTaskOfferController {
|
|||||||
InteractionBroadcaster broadcaster,
|
InteractionBroadcaster broadcaster,
|
||||||
InteractionService interactions,
|
InteractionService interactions,
|
||||||
SalesNotifier salesNotifier,
|
SalesNotifier salesNotifier,
|
||||||
RegistrationRepository registrationRepository) {
|
RegistrationRepository registrationRepository,
|
||||||
|
com.softspm.webinar.video.RoomPermissionSync permissionSync) {
|
||||||
|
this.permissionSync = permissionSync;
|
||||||
this.banners = banners;
|
this.banners = banners;
|
||||||
this.speakRequests = speakRequests;
|
this.speakRequests = speakRequests;
|
||||||
this.tasks = tasks;
|
this.tasks = tasks;
|
||||||
@@ -217,7 +220,10 @@ public class PublicTaskOfferController {
|
|||||||
@Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu")
|
@Operation(summary = "Wycofanie zgloszenia albo rezygnacja z przyznanego glosu")
|
||||||
public SpeakStateView withdrawSpeak(@PathVariable String token) {
|
public SpeakStateView withdrawSpeak(@PathVariable String token) {
|
||||||
TaskService.ParticipantDetails participant = participant(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) {
|
private TaskService.ParticipantDetails participant(String token) {
|
||||||
|
|||||||
+20
-5
@@ -1,5 +1,6 @@
|
|||||||
package com.softspm.webinar.interaction;
|
package com.softspm.webinar.interaction;
|
||||||
|
|
||||||
|
import com.softspm.webinar.video.RoomPermissionSync;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -30,9 +31,12 @@ import java.util.List;
|
|||||||
public class SpeakRequestAdminController {
|
public class SpeakRequestAdminController {
|
||||||
|
|
||||||
private final SpeakRequestService speakRequests;
|
private final SpeakRequestService speakRequests;
|
||||||
|
private final RoomPermissionSync permissionSync;
|
||||||
|
|
||||||
public SpeakRequestAdminController(SpeakRequestService speakRequests) {
|
public SpeakRequestAdminController(SpeakRequestService speakRequests,
|
||||||
|
RoomPermissionSync permissionSync) {
|
||||||
this.speakRequests = speakRequests;
|
this.speakRequests = speakRequests;
|
||||||
|
this.permissionSync = permissionSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -44,7 +48,11 @@ public class SpeakRequestAdminController {
|
|||||||
@PostMapping("/{requestId}/grant")
|
@PostMapping("/{requestId}/grant")
|
||||||
@Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.")
|
@Operation(summary = "Przyznanie glosu. Podnosi role uczestnika do SPEAKER.")
|
||||||
public SpeakRequestView grant(@PathVariable Long id, @PathVariable Long requestId) {
|
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")
|
@Operation(summary = "Wlaczenie albo wylaczenie trybu otwartego mikrofonu")
|
||||||
public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id,
|
public SpeakRequestService.OpenMicView setOpenMic(@PathVariable Long id,
|
||||||
@RequestBody OpenMicRequest request) {
|
@RequestBody OpenMicRequest request) {
|
||||||
return speakRequests.setOpenMic(id, request.openMic());
|
var result = speakRequests.setOpenMic(id, request.openMic());
|
||||||
|
permissionSync.syncWebinar(id);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/open-video")
|
@PutMapping("/open-video")
|
||||||
@Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich")
|
@Operation(summary = "Wlaczenie albo wylaczenie trybu wideo dla wszystkich")
|
||||||
public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id,
|
public SpeakRequestService.OpenVideoView setOpenVideo(@PathVariable Long id,
|
||||||
@RequestBody OpenVideoRequest request) {
|
@RequestBody OpenVideoRequest request) {
|
||||||
return speakRequests.setOpenVideo(id, request.openVideo());
|
var result = speakRequests.setOpenVideo(id, request.openVideo());
|
||||||
|
permissionSync.syncWebinar(id);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{requestId}")
|
@DeleteMapping("/{requestId}")
|
||||||
@Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.")
|
@Operation(summary = "Odebranie glosu albo odmowa. Przywraca role VIEWER.")
|
||||||
public SpeakRequestView revoke(@PathVariable Long id, @PathVariable Long requestId) {
|
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) {
|
public record OpenMicRequest(boolean openMic) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import io.livekit.server.RoomAdmin;
|
|||||||
import io.livekit.server.RoomJoin;
|
import io.livekit.server.RoomJoin;
|
||||||
import io.livekit.server.RoomName;
|
import io.livekit.server.RoomName;
|
||||||
import io.livekit.server.RoomRecord;
|
import io.livekit.server.RoomRecord;
|
||||||
|
import livekit.LivekitModels;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
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.
|
* 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
|
* ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje
|
||||||
* wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu.
|
* 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 existing = providerFactory.ofExistingRoom(webinar);
|
||||||
VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar);
|
VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar);
|
||||||
return LiveKitVideoProvider.NAME.equals(provider.name());
|
return LiveKitVideoProvider.NAME.equals(provider.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
private RoomServiceClient roomClient() {
|
RoomServiceClient roomClient() {
|
||||||
return RoomServiceClient.createClient(
|
return RoomServiceClient.createClient(
|
||||||
egressService.apiHttpUrl(),
|
egressService.apiHttpUrl(),
|
||||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||||
|
|||||||
@@ -259,32 +259,12 @@ public class VideoService {
|
|||||||
String displayName = (registration.getFirstName() + " " + registration.getLastName()).trim();
|
String displayName = (registration.getFirstName() + " " + registration.getLastName()).trim();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Uprawnienia do mowienia.
|
* Uprawnienia do mowienia i obrazu. Decyzja zapada przy wydawaniu
|
||||||
*
|
|
||||||
* Przy otwartym mikrofonie kazdy uczestnik dostaje SPEAKER, bez
|
|
||||||
* proszenia o glos. Decyzja musi zapasc **tutaj**, przy wydawaniu
|
|
||||||
* tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje,
|
* tokenu: samo odblokowanie przycisku w przegladarce niczego nie daje,
|
||||||
* bo dostawca i tak odrzuci publikacje bez uprawnienia w tokenie.
|
* bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
|
||||||
*
|
* spotkania przenosi na polaczonych `RoomPermissionSync`.
|
||||||
* Wideo zostaje wylaczone takze w tym trybie: SPEAKER u dostawcy
|
|
||||||
* oznacza u nas prawo do dzwieku, nie do obrazu.
|
|
||||||
*/
|
*/
|
||||||
/*
|
RoomRole role = participantRole(webinar, registration);
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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(), role));
|
||||||
@@ -294,6 +274,51 @@ public class VideoService {
|
|||||||
provider.name(), webinar.getRoomId());
|
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.
|
* 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,
|
MailCheck,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
MessageSquareOff,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Play,
|
Play,
|
||||||
@@ -106,6 +107,7 @@ const ICONS = {
|
|||||||
'mail-check': MailCheck,
|
'mail-check': MailCheck,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
'message-square': MessageSquare,
|
'message-square': MessageSquare,
|
||||||
|
'message-square-off': MessageSquareOff,
|
||||||
pencil: Pencil,
|
pencil: Pencil,
|
||||||
plus: Plus,
|
plus: Plus,
|
||||||
play: Play,
|
play: Play,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
LiveKitRoom as LiveKitRoomProvider,
|
LiveKitRoom as LiveKitRoomProvider,
|
||||||
RoomAudioRenderer,
|
RoomAudioRenderer,
|
||||||
useLocalParticipant,
|
useLocalParticipant,
|
||||||
|
useLocalParticipantPermissions,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { RoomOptions } from 'livekit-client'
|
import { RoomOptions } from 'livekit-client'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
|
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
|
||||||
import { WebinarStage } from './WebinarStage'
|
import { WebinarStage } from './WebinarStage'
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export function LiveKitRoom({
|
|||||||
initialAudio = false,
|
initialAudio = false,
|
||||||
initialVideo = false,
|
initialVideo = false,
|
||||||
onDevicesChange,
|
onDevicesChange,
|
||||||
|
speakGranted = false,
|
||||||
}: {
|
}: {
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
token: string
|
token: string
|
||||||
@@ -59,6 +61,12 @@ export function LiveKitRoom({
|
|||||||
initialVideo?: boolean
|
initialVideo?: boolean
|
||||||
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
|
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
|
||||||
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
|
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>(
|
const options = useMemo<RoomOptions>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -103,6 +111,7 @@ export function LiveKitRoom({
|
|||||||
<WebinarStage />
|
<WebinarStage />
|
||||||
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
|
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
|
||||||
<DeviceStateReporter onChange={onDevicesChange} />
|
<DeviceStateReporter onChange={onDevicesChange} />
|
||||||
|
<MicOnGrant granted={speakGranted} />
|
||||||
{/*
|
{/*
|
||||||
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
|
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
|
||||||
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
|
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
|
||||||
@@ -139,3 +148,34 @@ function DeviceStateReporter({
|
|||||||
|
|
||||||
return null
|
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:
|
* 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.
|
* 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.
|
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
|
||||||
*/
|
*/
|
||||||
if (speak.granted) {
|
if (speak.granted) {
|
||||||
@@ -224,7 +226,7 @@ function SpeakButton({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<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} />
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ function MeetingRoom({
|
|||||||
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
|
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
|
||||||
i banery zostaja nasze niezaleznie od tego wyboru.
|
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
|
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
|
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
|
||||||
* dziala jego gotowy komponent.
|
* 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.
|
* 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">
|
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||||
<Suspense fallback={<VideoLoading />}>
|
<Suspense fallback={<VideoLoading />}>
|
||||||
{/*
|
{/*
|
||||||
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
|
BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
|
Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
|
||||||
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
|
po stronie backendu), a biblioteka sama pokazuje albo chowa
|
||||||
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
|
przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
|
||||||
mikrofonu, dopoki uczestnik nie przeladuje strony.
|
przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
|
||||||
|
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
|
||||||
|
|
||||||
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje
|
Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
|
||||||
z `audio={false}`, wiec kazda opublikowana sciezka przepada
|
sali podmiana nic nie robi (`room.connect` przy stanie Connected
|
||||||
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od
|
konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
|
||||||
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny
|
po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
|
||||||
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.
|
|
||||||
*/}
|
*/}
|
||||||
<LiveKitRoom
|
<LiveKitRoom
|
||||||
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
|
key="sala"
|
||||||
serverUrl={join.url}
|
serverUrl={join.url}
|
||||||
token={join.token}
|
token={join.token}
|
||||||
/*
|
/*
|
||||||
@@ -454,6 +453,7 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
|||||||
onDevicesChange={(state) => {
|
onDevicesChange={(state) => {
|
||||||
devicesRef.current = state
|
devicesRef.current = state
|
||||||
}}
|
}}
|
||||||
|
speakGranted={speakGranted}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export function HostConsolePage() {
|
|||||||
}
|
}
|
||||||
toasts.success(
|
toasts.success(
|
||||||
kind === 'mute'
|
kind === 'mute'
|
||||||
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
|
? value ? 'Zablokowano pisanie na czacie' : 'Przywrócono pisanie na czacie'
|
||||||
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
|
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -499,7 +499,7 @@ function ChatColumn({
|
|||||||
</span>
|
</span>
|
||||||
{message.authorChatMuted && (
|
{message.authorChatMuted && (
|
||||||
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{message.authorRoomBanned && (
|
{message.authorRoomBanned && (
|
||||||
@@ -518,11 +518,11 @@ function ChatColumn({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
|
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
|
||||||
}
|
}
|
||||||
aria-label={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óć głos na czacie' : 'Wycisz 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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
|||||||
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
|
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
|
||||||
{ value: !participant.chatMuted },
|
{ 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()
|
await refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
|
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>
|
<span className="truncate font-medium text-slate-800">{participant.name}</span>
|
||||||
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
|
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
|
||||||
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</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>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||||
{participant.email && <span className="truncate">{participant.email}</span>}
|
{participant.email && <span className="truncate">{participant.email}</span>}
|
||||||
@@ -271,12 +271,12 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
|||||||
aria-label={
|
aria-label={
|
||||||
participant.chatMuted
|
participant.chatMuted
|
||||||
? `Przywróć pisanie na czacie: ${participant.name}`
|
? `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"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user