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
+22 -8
View File
@@ -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
@@ -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();
}
}
}
}
+2
View File
@@ -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,
+41 -1
View File
@@ -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"
+22 -22
View File
@@ -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